From 52b359b24913ddab5d12d42f2f113b0e63843063 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 3 Jun 2026 10:41:41 -0700 Subject: [PATCH 001/141] core: stop threading SandboxPolicy through exec (#25700) ## Why #25450 attempts a broad `SandboxPolicy` removal across several unrelated surfaces, which makes it hard to review and still leaves new helper code moving legacy policies around. This PR is a narrower alternative: migrate only the exec-side Windows sandbox plumbing so the review can focus on one production path and one compatibility boundary. The goal is to stop threading `SandboxPolicy` through exec code without expanding the migration into app-server, protocol, telemetry, config, or session behavior. ## What changed - Removed `ExecRequest::compatibility_sandbox_policy()`. - Changed the Windows restricted-token and elevated filesystem override helpers to accept `PermissionProfile` plus the split filesystem/network policies instead of a `SandboxPolicy`. - Kept the remaining legacy projection local to the writable-root comparison that still needs to compare split policy behavior against the legacy Windows backend model. - Rejected restricted split filesystem policies that still grant full-disk writes before using the Windows restricted-token backend, preserving the previous clear-failure behavior for profiles that project to `ExternalSandbox`. - Updated the Windows sandbox override tests to exercise the new call shape and cover the full-write split-profile regression. ## Verification - `just test -p codex-core windows_restricted_token` - `just test -p codex-core windows_elevated` --- .../src/request_processors/thread_summary.rs | 3 - codex-rs/core/src/codex_thread.rs | 3 - codex-rs/core/src/config/mod.rs | 8 +- codex-rs/core/src/exec.rs | 109 ++++---- codex-rs/core/src/exec_tests.rs | 239 ++++++++---------- codex-rs/core/src/sandboxing/mod.rs | 11 - codex-rs/core/src/session/session.rs | 16 +- codex-rs/core/src/session/turn_context.rs | 4 - codex-rs/core/src/spawn.rs | 2 +- codex-rs/sandboxing/src/manager.rs | 5 +- 10 files changed, 167 insertions(+), 233 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index da7ae54804..a57f2fcf79 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -179,11 +179,8 @@ pub(crate) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, ) -> codex_app_server_protocol::SandboxPolicy { - let file_system_policy = permission_profile.file_system_sandbox_policy(); let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( permission_profile, - &file_system_policy, - permission_profile.network_sandbox_policy(), cwd, ); sandbox_policy.into() diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cd90829c0c..5035796767 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -76,11 +76,8 @@ pub struct ThreadConfigSnapshot { impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { - let file_system_sandbox_policy = self.permission_profile.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &self.permission_profile, - &file_system_sandbox_policy, - self.permission_profile.network_sandbox_policy(), self.cwd.as_path(), ) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 43b2a2ed29..28e97c36a4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -444,13 +444,7 @@ impl Permissions { /// Legacy compatibility projection derived from the canonical profile. pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy { let permission_profile = self.materialized_permission_profile(); - let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); - compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, - permission_profile.network_sandbox_policy(), - cwd, - ) + compatibility_sandbox_policy_for_permission_profile(&permission_profile, cwd) } /// Check whether a legacy sandbox policy can be applied to this permission diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index e050552246..8e87528df0 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -31,19 +31,18 @@ use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecOutputStream; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -419,22 +418,17 @@ pub fn build_exec_request( exec_req.windows_sandbox_level, exec_req.network.is_some(), ); - let sandbox_policy = exec_req.compatibility_sandbox_policy(); exec_req.windows_sandbox_filesystem_overrides = if use_windows_elevated_backend { resolve_windows_elevated_filesystem_overrides( exec_req.sandbox, - &sandbox_policy, - &exec_req.file_system_sandbox_policy, - exec_req.network_sandbox_policy, + &exec_req.permission_profile, sandbox_cwd, use_windows_elevated_backend, ) } else { resolve_windows_restricted_token_filesystem_overrides( exec_req.sandbox, - &sandbox_policy, - &exec_req.file_system_sandbox_policy, - exec_req.network_sandbox_policy, + &exec_req.permission_profile, sandbox_cwd, exec_req.windows_sandbox_level, ) @@ -1004,34 +998,28 @@ async fn exec( } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -fn should_use_windows_restricted_token_sandbox( - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, +fn permission_profile_supports_windows_restricted_token_sandbox( + permission_profile: &PermissionProfile, ) -> bool { - sandbox == SandboxType::WindowsRestrictedToken - && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted - && !matches!( - sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) + match permission_profile { + PermissionProfile::Managed { file_system, .. } => { + !file_system.to_sandbox_policy().has_full_disk_write_access() + } + PermissionProfile::Disabled | PermissionProfile::External { .. } => false, + } } #[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, + permission_profile: &PermissionProfile, sandbox_policy_cwd: &AbsolutePathBuf, windows_sandbox_level: WindowsSandboxLevel, ) -> Option { if windows_sandbox_level == WindowsSandboxLevel::Elevated { resolve_windows_elevated_filesystem_overrides( sandbox, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, + permission_profile, sandbox_policy_cwd, windows_sandbox_level == WindowsSandboxLevel::Elevated, ) @@ -1039,9 +1027,7 @@ pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( } else { resolve_windows_restricted_token_filesystem_overrides( sandbox, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, + permission_profile, sandbox_policy_cwd, windows_sandbox_level, ) @@ -1051,9 +1037,7 @@ pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, + permission_profile: &PermissionProfile, sandbox_policy_cwd: &AbsolutePathBuf, windows_sandbox_level: WindowsSandboxLevel, ) -> std::result::Result, String> { @@ -1063,25 +1047,22 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( return Ok(None); } + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + 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 + if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) + && !needs_direct_runtime_enforcement { return Ok(None); } - if !should_use_windows_restricted_token_sandbox( - sandbox, - sandbox_policy, - file_system_sandbox_policy, - ) { + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", file_system_sandbox_policy.kind, )); } @@ -1090,7 +1071,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs // participate in read access checks. Read restrictions therefore require the // elevated backend, even when the filesystem root remains readable. - if !windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd) { + if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { return Err( "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" .to_string(), @@ -1098,7 +1079,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( } let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - file_system_sandbox_policy, + &file_system_sandbox_policy, sandbox_policy_cwd, )?; if !additional_deny_read_paths.is_empty() { @@ -1108,7 +1089,11 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( ); } - let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.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 @@ -1204,9 +1189,7 @@ fn windows_policy_has_root_read_access( pub(crate) fn resolve_windows_elevated_filesystem_overrides( sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, + permission_profile: &PermissionProfile, sandbox_policy_cwd: &AbsolutePathBuf, use_windows_elevated_backend: bool, ) -> std::result::Result, String> { @@ -1214,19 +1197,19 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( return Ok(None); } - if !should_use_windows_restricted_token_sandbox( - sandbox, - sandbox_policy, - file_system_sandbox_policy, - ) { + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", file_system_sandbox_policy.kind, )); } let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - file_system_sandbox_policy, + &file_system_sandbox_policy, sandbox_policy_cwd, )?; @@ -1242,7 +1225,11 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( let needs_direct_runtime_enforcement = file_system_sandbox_policy .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); - let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); let legacy_root_paths: BTreeSet = legacy_writable_roots .iter() .map(|root| normalize_path(root.root.to_path_buf())) @@ -1264,7 +1251,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( // whether the baseline still reads from the filesystem root and only needs // additional deny ACLs layered on top. let split_has_root_read_access = - windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd); + windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); let read_roots_override = if split_has_root_read_access { None } else { @@ -1329,6 +1316,14 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( })) } +fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { + match permission_profile { + PermissionProfile::Managed { .. } => "Managed", + PermissionProfile::Disabled => "Disabled", + PermissionProfile::External { .. } => "External", + } +} + fn has_reopened_writable_descendant( writable_roots: &[codex_protocol::protocol::WritableRoot], ) -> bool { diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 1c71d02651..84dfaba5d7 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -379,34 +379,18 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result #[test] fn windows_restricted_token_skips_external_sandbox_policies() { - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); - assert_eq!( - should_use_windows_restricted_token_sandbox( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - ), - false - ); + assert!(!permission_profile_supports_windows_restricted_token_sandbox(&permission_profile)); } #[test] -fn windows_restricted_token_runs_for_legacy_restricted_policies() { - let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::from(&policy); +fn windows_restricted_token_supports_read_only_profiles() { + let permission_profile = PermissionProfile::read_only(); - assert_eq!( - should_use_windows_restricted_token_sandbox( - SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - ), - true - ); + assert!(permission_profile_supports_windows_restricted_token_sandbox(&permission_profile)); } #[test] @@ -427,39 +411,64 @@ fn windows_proxy_enforcement_uses_elevated_backend() { #[test] fn windows_restricted_token_rejects_network_only_restrictions() { - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }; - let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Restricted, + ); let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd"); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &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() + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, permission_profile=Managed; 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::from(&policy); +fn windows_restricted_token_rejects_managed_root_write_profiles() { + 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, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd"); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Some( + "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, permission_profile=Managed; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_allows_read_only_profiles() { + let permission_profile = PermissionProfile::read_only(); + let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd"); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &permission_profile, &sandbox_policy_cwd, WindowsSandboxLevel::RestrictedToken, ), @@ -468,22 +477,19 @@ fn windows_restricted_token_allows_legacy_restricted_policies() { } #[test] -fn windows_restricted_token_allows_legacy_workspace_write_policies() { - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); +fn windows_restricted_token_allows_workspace_write_profiles() { + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd"); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &sandbox_policy_cwd, WindowsSandboxLevel::RestrictedToken, ), @@ -499,22 +505,21 @@ fn windows_elevated_allows_split_restricted_read_policies() { ) .expect("absolute docs"); std::fs::create_dir_all(docs.as_path()).expect("create docs"); - let policy = SandboxPolicy::ReadOnly { - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Path { path: docs }, access: codex_protocol::permissions::FileSystemAccessMode::Read, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), WindowsSandboxLevel::Elevated, ), @@ -527,12 +532,6 @@ 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![], - 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 { @@ -550,13 +549,15 @@ fn windows_restricted_token_rejects_split_only_filesystem_policies() { access: codex_protocol::permissions::FileSystemAccessMode::Read, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), WindowsSandboxLevel::RestrictedToken, ), @@ -572,12 +573,6 @@ 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![], - 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 { @@ -593,13 +588,15 @@ fn windows_restricted_token_rejects_root_write_read_only_carveouts() { access: codex_protocol::permissions::FileSystemAccessMode::Read, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), WindowsSandboxLevel::RestrictedToken, ), @@ -618,12 +615,6 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { .abs(); let docs = cwd.join("docs"); std::fs::create_dir_all(docs.as_path()).expect("create docs"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - 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 { @@ -644,17 +635,20 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { access: codex_protocol::permissions::FileSystemAccessMode::Read, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); - // The legacy workspace-write root already protects top-level `.codex`, so - // the restricted-token overlay only needs the extra read-only docs carveout. + // The workspace-write compatibility projection already protects top-level + // `.codex`, so the restricted-token overlay only needs the extra read-only + // docs carveout. let expected_deny_write_paths = vec![docs]; assert_eq!( resolve_windows_restricted_token_filesystem_overrides( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &cwd, WindowsSandboxLevel::RestrictedToken, ), @@ -676,12 +670,6 @@ fn windows_restricted_token_rejects_unreadable_split_carveouts() { .abs(); let blocked = cwd.join("blocked"); std::fs::create_dir_all(blocked.as_path()).expect("create blocked"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - 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 { @@ -702,13 +690,15 @@ fn windows_restricted_token_rejects_unreadable_split_carveouts() { access: codex_protocol::permissions::FileSystemAccessMode::Deny, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( resolve_windows_restricted_token_filesystem_overrides( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &cwd, WindowsSandboxLevel::RestrictedToken, ), @@ -725,9 +715,6 @@ fn windows_elevated_supports_split_restricted_read_roots() { let docs = temp_dir.path().join("docs"); std::fs::create_dir_all(&docs).expect("create docs"); let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); - let policy = SandboxPolicy::ReadOnly { - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Path { @@ -737,13 +724,15 @@ fn windows_elevated_supports_split_restricted_read_roots() { access: codex_protocol::permissions::FileSystemAccessMode::Read, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( resolve_windows_elevated_filesystem_overrides( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), /*use_windows_elevated_backend*/ true, ), @@ -763,12 +752,6 @@ fn windows_elevated_supports_split_write_read_carveouts() { let docs = temp_dir.path().join("docs"); std::fs::create_dir_all(&docs).expect("create docs"); let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - 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 { @@ -792,13 +775,15 @@ fn windows_elevated_supports_split_write_read_carveouts() { access: codex_protocol::permissions::FileSystemAccessMode::Read, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( resolve_windows_elevated_filesystem_overrides( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), /*use_windows_elevated_backend*/ true, ), @@ -821,12 +806,6 @@ fn windows_elevated_supports_unreadable_split_carveouts() { let blocked = temp_dir.path().join("blocked"); std::fs::create_dir_all(&blocked).expect("create blocked"); let expected_blocked = dunce::canonicalize(&blocked).expect("canonical blocked"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - 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 { @@ -850,13 +829,15 @@ fn windows_elevated_supports_unreadable_split_carveouts() { access: codex_protocol::permissions::FileSystemAccessMode::Deny, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( resolve_windows_elevated_filesystem_overrides( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), /*use_windows_elevated_backend*/ true, ), @@ -884,12 +865,6 @@ fn windows_elevated_supports_unreadable_globs() { let secret = temp_dir.path().join("app").join(".env"); std::fs::create_dir_all(secret.parent().expect("parent")).expect("create parent"); std::fs::write(&secret, "secret").expect("write secret"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - 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 { @@ -912,13 +887,15 @@ fn windows_elevated_supports_unreadable_globs() { access: codex_protocol::permissions::FileSystemAccessMode::Deny, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( resolve_windows_elevated_filesystem_overrides( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), /*use_windows_elevated_backend*/ true, ), @@ -941,12 +918,6 @@ fn windows_elevated_rejects_reopened_writable_descendants() { let docs = temp_dir.path().join("docs"); let nested = docs.join("nested"); std::fs::create_dir_all(&nested).expect("create nested"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - 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 { @@ -977,13 +948,15 @@ fn windows_elevated_rejects_reopened_writable_descendants() { access: codex_protocol::permissions::FileSystemAccessMode::Write, }, ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, + &permission_profile, &temp_dir.path().abs(), WindowsSandboxLevel::Elevated, ), diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f853ea3ba6..8a2a2849fd 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -22,10 +22,8 @@ use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; -use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -102,15 +100,6 @@ impl ExecRequest { } } - pub(crate) fn compatibility_sandbox_policy(&self) -> SandboxPolicy { - compatibility_sandbox_policy_for_permission_profile( - &self.permission_profile, - &self.file_system_sandbox_policy, - self.network_sandbox_policy, - self.windows_sandbox_policy_cwd.as_path(), - ) - } - pub(crate) fn from_sandbox_exec_request( request: SandboxExecRequest, options: ExecOptions, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6ab7e86f32..07d249613a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -150,17 +150,11 @@ impl SessionConfiguration { } pub(super) fn sandbox_policy(&self) -> SandboxPolicy { - self.permission_profile() - .to_legacy_sandbox_policy(&self.cwd) - .unwrap_or_else(|_| { - let file_system_sandbox_policy = self.file_system_sandbox_policy(); - codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - self.permission_profile_state.permission_profile(), - &file_system_sandbox_policy, - self.network_sandbox_policy(), - &self.cwd, - ) - }) + let permission_profile = self.permission_profile(); + codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + &self.cwd, + ) } pub(super) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 166f8cea81..a6fcfca787 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -124,12 +124,8 @@ impl TurnContext { } pub(crate) fn sandbox_policy(&self) -> SandboxPolicy { - let file_system_sandbox_policy = self.file_system_sandbox_policy(); - let network_sandbox_policy = self.network_sandbox_policy(); compatibility_sandbox_policy_for_permission_profile( &self.permission_profile, - &file_system_sandbox_policy, - network_sandbox_policy, #[allow(deprecated)] &self.cwd, ) diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index a2c4ebe597..a23a1d749e 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -30,7 +30,7 @@ pub enum StdioPolicy { Inherit, } -/// Spawns the appropriate child process for the ExecParams and SandboxPolicy, +/// Spawns the appropriate child process for the exec params and sandbox settings, /// ensuring the args and environment variables used to create the `Command` /// (and `Child`) honor the configuration. /// diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index bd8f6d9b98..39ef358f8c 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -290,19 +290,18 @@ impl SandboxManager { pub fn compatibility_sandbox_policy_for_permission_profile( permissions: &PermissionProfile, - file_system_policy: &FileSystemSandboxPolicy, - network_policy: NetworkSandboxPolicy, cwd: &Path, ) -> SandboxPolicy { permissions .to_legacy_sandbox_policy(cwd) .unwrap_or_else(|_| { + let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); compatibility_workspace_write_policy(file_system_policy, network_policy, cwd) }) } fn compatibility_workspace_write_policy( - file_system_policy: &FileSystemSandboxPolicy, + file_system_policy: FileSystemSandboxPolicy, network_policy: NetworkSandboxPolicy, cwd: &Path, ) -> SandboxPolicy { From 2d5c264ebc26c276ca6cc312389abde453ca69aa Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 3 Jun 2026 11:29:36 -0700 Subject: [PATCH 002/141] [codex] Copy user Bazel settings into Codex worktrees (#25925) ## Why Codex-created linked worktrees do not include ignored files from the main worktree. Bazel users who keep local overrides in `user.bazelrc` therefore lose those settings in every new worktree. The setup must also work on Windows and must not overwrite a file that already exists in the worktree. ## What changed The checked-in Codex environment now invokes `.codex/environments/setup.py`. The script resolves the main worktree and current worktree, then uses `copy_from_main_worktree_to_worktree(repo_relative_path)` to copy ignored files into new worktrees without overwriting existing destinations. `main()` currently copies `user.bazelrc`. Additional repository-relative paths can be added as further calls to the same helper. ## Validation - Ran the setup script in a linked worktree and confirmed it handles a missing main-worktree `user.bazelrc`. - Verified the helper copies a main-worktree file, preserves an existing worktree file, and creates parent directories for a nested path. --- .codex/environments/environment.toml | 2 +- .codex/environments/setup.py | 65 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .codex/environments/setup.py diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index 4595a1fa65..39c40eb07f 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -3,7 +3,7 @@ version = 1 name = "codex" [setup] -script = "" +script = "python ./.codex/environments/setup.py" [[actions]] name = "Run" diff --git a/.codex/environments/setup.py b/.codex/environments/setup.py new file mode 100644 index 0000000000..a9d78c9405 --- /dev/null +++ b/.codex/environments/setup.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +"""Set up ignored files that should be shared with Codex worktrees.""" + +import shutil +import subprocess +from functools import cache +from pathlib import Path + + +@cache +def worktree_paths() -> tuple[Path, Path]: + script_dir = Path(__file__).resolve().parent + worktree_root = git_path(script_dir / "../..", "--show-toplevel") + common_git_dir = git_path(worktree_root, "--git-common-dir") + return worktree_root, common_git_dir.parent + + +def git_path(working_directory: Path, argument: str) -> Path: + output = subprocess.check_output( + [ + "git", + "-C", + str(working_directory), + "rev-parse", + "--path-format=absolute", + argument, + ], + text=True, + ) + return Path(output.strip()) + + +def copy_from_main_worktree_to_worktree(repo_relative_path: str) -> None: + relative_path = Path(repo_relative_path) + if relative_path.is_absolute() or ".." in relative_path.parts: + raise ValueError(f"path must be repository-relative: {repo_relative_path}") + + worktree_root, main_worktree = worktree_paths() + source_path = main_worktree / relative_path + destination_path = worktree_root / relative_path + + print(f" source: {source_path}") + print(f" destination: {destination_path}") + + if source_path == destination_path: + print(" result: running in the main worktree; nothing to copy") + elif destination_path.exists(): + print(" result: destination already exists; nothing to copy") + elif not source_path.is_file(): + print(" result: source does not exist; nothing to copy") + else: + destination_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination_path) + print(f" result: copied {repo_relative_path}") + + +def main() -> None: + print("Codex environment setup:") + # See codex-rs/docs/bazel.md for the repository's Bazel workflow. + copy_from_main_worktree_to_worktree("user.bazelrc") + + +if __name__ == "__main__": + main() From 10b408080a1009ca99be50cf8bc3fe45dfe7223b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Jun 2026 12:14:14 -0700 Subject: [PATCH 003/141] [codex] Pin Python SDK to runtime 0.137.0a4 (#26216) ## Summary - pin the Python SDK runtime to `openai-codex-cli-bin==0.137.0a4` - refresh generated protocol artifacts from `rust-v0.137.0-alpha.4` - refresh `sdk/python/uv.lock` with all eight published runtime wheels ## Runtime publication - published `openai-codex-cli-bin==0.137.0a4` through the `python-sdk-release` workflow - includes macOS, manylinux, musllinux, and Windows wheels - publication run: https://github.com/openai/codex/actions/runs/26905608531 ## Validation - ran `just fmt` - generated artifacts from the `rust-v0.137.0-alpha.4` release wheel - ran `uv lock --check --default-index https://pypi.org/simple` - did not run tests locally, per request; CI provides the test signal --- sdk/python/pyproject.toml | 6 +-- .../src/openai_codex/generated/v2_all.py | 40 +++++++++++++++++++ .../test_artifact_workflow_and_binaries.py | 20 +++++----- sdk/python/tests/test_contract_generation.py | 2 +- sdk/python/uv.lock | 20 +++++----- 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 6b7bbddd87..b70df0cb53 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.136.0"] +dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.137.0a4"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -78,10 +78,10 @@ combine-as-imports = true [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-03T19:00:00Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-03T19:00:00Z" } index-strategy = "first-index" diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index c27a8f75ca..15ede1801c 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -602,6 +602,22 @@ class SystemConfigLayerSource(BaseModel): type: Annotated[Literal["system"], Field(title="SystemConfigLayerSourceType")] +class EnterpriseManagedConfigLayerSource(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: Annotated[str, Field(description="Stable identifier for the delivered layer.")] + name: Annotated[ + str, + Field( + description="Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention." + ), + ] + type: Annotated[ + Literal["enterpriseManaged"], Field(title="EnterpriseManagedConfigLayerSourceType") + ] + + class UserConfigLayerSource(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -661,6 +677,7 @@ class ConfigLayerSource( RootModel[ MdmConfigLayerSource | SystemConfigLayerSource + | EnterpriseManagedConfigLayerSource | UserConfigLayerSource | ProjectConfigLayerSource | SessionFlagsConfigLayerSource @@ -674,6 +691,7 @@ class ConfigLayerSource( root: ( MdmConfigLayerSource | SystemConfigLayerSource + | EnterpriseManagedConfigLayerSource | UserConfigLayerSource | ProjectConfigLayerSource | SessionFlagsConfigLayerSource @@ -1513,6 +1531,7 @@ class HookSource(Enum): session_flags = "sessionFlags" plugin = "plugin" cloud_requirements = "cloudRequirements" + cloud_managed_config = "cloudManagedConfig" legacy_managed_config_file = "legacyManagedConfigFile" legacy_managed_config_mdm = "legacyManagedConfigMdm" unknown = "unknown" @@ -3578,6 +3597,16 @@ class SortDirection(Enum): desc = "desc" +class SpendControlLimitSnapshot(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + limit: str + remaining_percent: Annotated[int, Field(alias="remainingPercent")] + resets_at: Annotated[int, Field(alias="resetsAt")] + used: str + + class SubAgentSourceValue(Enum): review = "review" compact = "compact" @@ -4657,6 +4686,7 @@ class AppConfig(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + approvals_reviewer: ApprovalsReviewer | None = None default_tools_approval_mode: AppToolApproval | None = None default_tools_enabled: bool | None = None destructive_enabled: bool | None = None @@ -6118,6 +6148,9 @@ class RateLimitSnapshot(BaseModel): populate_by_name=True, ) credits: CreditsSnapshot | None = None + individual_limit: Annotated[ + SpendControlLimitSnapshot | None, Field(alias="individualLimit") + ] = None limit_id: Annotated[str | None, Field(alias="limitId")] = None limit_name: Annotated[str | None, Field(alias="limitName")] = None plan_type: Annotated[PlanType | None, Field(alias="planType")] = None @@ -8011,6 +8044,13 @@ class Thread(BaseModel): ), ] name: Annotated[str | None, Field(description="Optional user-facing thread title.")] = None + parent_thread_id: Annotated[ + str | None, + Field( + alias="parentThreadId", + description="The ID of the parent thread. This will only be set if this thread is a subagent.", + ), + ] = None path: Annotated[str | None, Field(description="[UNSTABLE] Path to the thread on disk.")] = None preview: Annotated[ str, Field(description="Usually the first user message in the thread, if available.") diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 5a8f1275d6..501f1e100a 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -261,13 +261,13 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( assert flattened == [ "MessagePhase", "TurnItemsView", - "PluginAvailability", "AuthMode", + "PluginAvailability", "InputModality", "ExperimentalFeatureStage", - "AutoCompactTokenLimitScope", - "CommandExecOutputStream", "ProcessOutputStream", + "CommandExecOutputStream", + "AutoCompactTokenLimitScope", ] @@ -358,10 +358,10 @@ def test_source_sdk_template_pins_published_runtime() -> None: "dependencies": pyproject["project"]["dependencies"], } == { "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.136.0", + "runtime_pin": "0.137.0a4", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.136.0", + "openai-codex-cli-bin==0.137.0a4", ], } @@ -436,7 +436,7 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None: } == { "package_name": "openai-codex-cli-bin", "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.136.0", + "runtime_pin": "0.137.0a4", "normalized_release_version": "0.116.0a1", "release_tag": "rust-v0.116.0-alpha.1", } @@ -651,7 +651,7 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non "version": "0.1.0b1", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.136.0", + "openai-codex-cli-bin==0.137.0a4", ], } assert ( @@ -688,7 +688,7 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: ) runtime_stage = script.stage_python_runtime_package( tmp_path / "runtime-stage", - "0.136.0", + "0.137.0a4", package_archive, ) @@ -701,10 +701,10 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: "sdk_dependencies": sdk_pyproject["project"]["dependencies"], } == { "sdk_version": "0.1.0b1", - "runtime_version": "0.136.0", + "runtime_version": "0.137.0a4", "sdk_dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.136.0", + "openai-codex-cli-bin==0.137.0a4", ], } diff --git a/sdk/python/tests/test_contract_generation.py b/sdk/python/tests/test_contract_generation.py index a6d3d2fdc4..36d37735c7 100644 --- a/sdk/python/tests/test_contract_generation.py +++ b/sdk/python/tests/test_contract_generation.py @@ -40,7 +40,7 @@ def test_generated_files_are_up_to_date(): # Regenerate contract artifacts via the pinned runtime package, not a local # app-server binary from the checkout or CI environment. - assert importlib.metadata.version("openai-codex-cli-bin") == "0.136.0" + assert importlib.metadata.version("openai-codex-cli-bin") == "0.137.0a4" env = os.environ.copy() env.pop("CODEX_EXEC_PATH", None) python_bin = str(Path(sys.executable).parent) diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 9dabd34d71..588c9f4fe2 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -7,7 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -openai-codex-cli-bin = "2026-06-01T20:00:00Z" +openai-codex-cli-bin = "2026-06-03T19:00:00Z" [[package]] name = "annotated-types" @@ -299,7 +299,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" }, - { name = "openai-codex-cli-bin", specifier = "==0.136.0" }, + { name = "openai-codex-cli-bin", specifier = "==0.137.0a4" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.8" }, @@ -308,15 +308,17 @@ provides-extras = ["dev"] [[package]] name = "openai-codex-cli-bin" -version = "0.136.0" +version = "0.137.0a4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/2e/09764338fcb104ebcb1ebf2490d71ab4b8339b5d6f8bdce19480adad5a99/openai_codex_cli_bin-0.136.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9ded24a9977221f67173f49b8c3ba3a944787b7323dbd664dd8e8ae1a5efc0aa", size = 94036262, upload-time = "2026-06-01T19:28:13.291Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/10dea458f8ead97b6449a1c05b65883ffe28724204c05e6169439576ee48/openai_codex_cli_bin-0.136.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d194688c1afe87bf85705528cbcbdb762cb378d8c58c64162402babbd795436", size = 83713829, upload-time = "2026-06-01T19:28:17.39Z" }, - { url = "https://files.pythonhosted.org/packages/7d/83/21402a805f3312196a4f4bef0b6a45d3dee301e3db3ddeee117b4b1e612b/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:bdbdfeeac5bb2e744bd810cb60c55313c280d0f6ff9e93699431bcfc2040c9c2", size = 82981863, upload-time = "2026-06-01T19:28:21.246Z" }, - { url = "https://files.pythonhosted.org/packages/33/79/1617e008381345391def39254404c0248a425739bd13c95d9fbe3e63d16f/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:37b353435f354c6d1bcc32e3b83e40ef9d1ef7294c8adedd229fbaedc82a6f74", size = 90187224, upload-time = "2026-06-01T19:28:24.89Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bc/a591d4464f999e0fab6abb5ba369463eee99a82e7c298e2cff257d80b849/openai_codex_cli_bin-0.136.0-py3-none-win_amd64.whl", hash = "sha256:c91ffbb1d903970e74b65958f7d1fa548fac8abdfe9b3a098f3da7f47dbb2575", size = 91137642, upload-time = "2026-06-01T19:28:28.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/f3/e8ccd644e7a87a25229022088abf4d724d3d608fcda7123f9a11515409ca/openai_codex_cli_bin-0.136.0-py3-none-win_arm64.whl", hash = "sha256:9073f7283c47ad3e585e23598ebbef554e85d280cabb637b6cb9a078c5e7086a", size = 83623904, upload-time = "2026-06-01T19:28:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/bd/60/af73ef1676cd477fa83ed4b889bf3b57c63c47dd87025b2cc4262793cff6/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b33c3917e0b58d527ee11a11a78ad390f7d8e6aa25577dd21665ab3c8bf5cf9a", size = 94300191, upload-time = "2026-06-03T18:44:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/d1a5f8c87176e00ef6a85798794f4530f5eb04e5a1a13468b5b3c3a361f9/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3d0f0bc5becc88c61952fbfa9bd792ac9d74fa78b3a6bd40f545b612048b07eb", size = 83924479, upload-time = "2026-06-03T18:44:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fc00bcdc0c302208317d5eb1d0bfaab3024f351cd0121400f19baa6b19aa/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f1656339e2736868c4cce59f6d9e5c633879123687169b03b1137d42bf2c11a", size = 83363315, upload-time = "2026-06-03T18:44:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/39362e944ebeb12fcbfb86881fbb4dd6e806f77f7541c1f1f993bb9351a0/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6454f838d44c56c1ed07a29b391fa412785e5dd2ffd06db0b62e62478c19bb64", size = 90611239, upload-time = "2026-06-03T18:44:49.338Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/87b1247fdfe95cddce7f7fe8331d6843cf037e14292c0f5004e23247133b/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f5ae7401d00c65d56a75d9645d7bf87d809566a12d238e4b2a8b328a02f2316e", size = 83363315, upload-time = "2026-06-03T18:44:53.428Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c4/3c693ad07e587f6b3a28128c417f2e831d81a40cdbd85c0e5f0f36aaff82/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3dcec1e649448be498d6e7ec0e1f71dca83efa76063d90890dafb41e987069b7", size = 90611238, upload-time = "2026-06-03T18:44:57.612Z" }, + { url = "https://files.pythonhosted.org/packages/9e/26/81e037066b9b8d312a6f9e09015e452ce17630d5ab88e02a4c1d9503e4e8/openai_codex_cli_bin-0.137.0a4-py3-none-win_amd64.whl", hash = "sha256:9e13bf68e18e36bd3a0efd51213281c83e9f6ec22bdb7a45bd2e0211822733a9", size = 94744969, upload-time = "2026-06-03T18:45:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/952bc2a5d62373a51fea161effe3b338b3417c2f6e65fe467ed91b205e2b/openai_codex_cli_bin-0.137.0a4-py3-none-win_arm64.whl", hash = "sha256:5ec4303ca2dcb5f838e0de3ca7f44050b6bcdd41d281a178c3a1420a985a515d", size = 86963504, upload-time = "2026-06-03T18:45:07.131Z" }, ] [[package]] From aeac226d16523875a3fc77f61d6edcd2943155fc Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Wed, 3 Jun 2026 12:39:13 -0700 Subject: [PATCH 004/141] Preserve remote plugin default prompts (#25887) ## Summary - Read `default_prompts` from remote plugin release metadata. - Prefer the plural prompt list over legacy `default_prompt`. - Fall back to `default_prompt` as a single-item list for backward compatibility. ## Testing - `just test -p codex-core-plugins` - `just test -p codex-app-server` --- .../app-server/tests/suite/v2/plugin_list.rs | 16 +++++++++++ .../app-server/tests/suite/v2/plugin_read.rs | 27 +++++++++++++++++- codex-rs/core-plugins/src/remote.rs | 28 +++++++++++++++---- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 0df7c82eef..ae622db8a8 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1754,6 +1754,8 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], + "default_prompt": "Use the legacy Linear prompt", + "default_prompts": ["Create a Linear issue", "Review my Linear projects"], "logo_url": "https://example.com/linear.png", "screenshot_urls": ["https://example.com/linear-shot.png"] }, @@ -1893,6 +1895,16 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and_then(|interface| interface.display_name.as_deref()), Some("Linear") ); + assert_eq!( + remote_marketplace.plugins[0] + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec![ + "Create a Linear issue".to_string(), + "Review my Linear projects".to_string(), + ]) + ); assert_eq!( remote_marketplace.plugins[0].keywords, vec![ @@ -1907,6 +1919,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache_files[0])?)?; assert_eq!(cached_catalog["schema_version"], serde_json::json!(1)); + assert_eq!( + cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"], + serde_json::json!(["Create a Linear issue", "Review my Linear projects"]) + ); let cached_plugin_ids = cached_catalog["plugins"] .as_array() .expect("cached plugins should be an array") diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 1f1c217ee5..f78cd2a088 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -159,7 +159,9 @@ plugins = true "keywords": [], "interface": { "short_description": "Plan and track work", - "capabilities": [] + "capabilities": [], + "default_prompt": "Use the legacy Linear prompt", + "default_prompts": [] }, "skills": [] } @@ -217,6 +219,15 @@ plugins = true assert_eq!(response.plugin.summary.name, "linear"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec!["Use the legacy Linear prompt".to_string()]) + ); Ok(()) } @@ -404,6 +415,8 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], + "default_prompt": "Use the legacy Linear prompt", + "default_prompts": ["Create a Linear issue", "Review my Linear projects"], "logo_url": "https://example.com/linear.png", "screenshot_urls": ["https://example.com/linear-shot.png"] }, @@ -518,6 +531,18 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "project management".to_string() ] ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec![ + "Create a Linear issue".to_string(), + "Review my Linear projects".to_string(), + ]) + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!(response.plugin.skills[0].name, "plan-work"); assert_eq!(response.plugin.skills[0].path, None); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 3c86f6b14b..045ad8e184 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -66,6 +66,7 @@ pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &st const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; +const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [ @@ -392,6 +393,7 @@ struct RemotePluginReleaseInterfaceResponse { terms_of_service_url: Option, brand_color: Option, default_prompt: Option, + default_prompts: Option>, composer_icon_url: Option, logo_url: Option, #[serde(default)] @@ -1193,9 +1195,16 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option let interface = &plugin.release.interface; let display_name = non_empty_string(Some(&plugin.release.display_name)); let default_prompt = interface - .default_prompt - .as_ref() - .and_then(|prompt| normalize_remote_default_prompt(prompt)); + .default_prompts + .as_deref() + .and_then(normalize_remote_default_prompts) + .or_else(|| { + interface + .default_prompt + .as_deref() + .and_then(normalize_remote_default_prompt) + .map(|prompt| vec![prompt]) + }); let result = PluginInterface { display_name, short_description: interface.short_description.clone(), @@ -1279,12 +1288,21 @@ fn non_empty_string(value: Option<&str>) -> Option { }) } -fn normalize_remote_default_prompt(prompt: &str) -> Option> { +fn normalize_remote_default_prompts(prompts: &[String]) -> Option> { + let prompts = prompts + .iter() + .filter_map(|prompt| normalize_remote_default_prompt(prompt)) + .take(MAX_REMOTE_DEFAULT_PROMPT_COUNT) + .collect::>(); + (!prompts.is_empty()).then_some(prompts) +} + +fn normalize_remote_default_prompt(prompt: &str) -> Option { let prompt = prompt.trim(); if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { return None; } - Some(vec![prompt.to_string()]) + Some(prompt.to_string()) } async fn fetch_directory_plugins_for_scope( From 57ab4c89e05d13b81722780ffc50eee0ea6cf445 Mon Sep 17 00:00:00 2001 From: Won Park Date: Wed, 3 Jun 2026 12:49:58 -0700 Subject: [PATCH 005/141] Expose local image paths to models (#25944) ## Why Local image attachments include image bytes, but the adjacent model-visible label omits the source path. Exposing the path lets model-selected workflows refer back to the intended local image explicitly. ## What changed - Include an escaped `path` attribute in model-visible local image opening tags. - Reuse the path-aware marker generator in rollout coverage. - Update protocol, replay, and rollout coverage for the new request shape. ## Validation - `just fmt` - `just test -p codex-protocol` - `just test -p codex-core skips_local_image_label_text` - `just test -p codex-core copy_paste_local_image_persists_rollout_request_shape` - `git diff --check` --- codex-rs/core/src/event_mapping_tests.rs | 2 +- codex-rs/core/tests/suite/image_rollout.rs | 4 +++- codex-rs/protocol/src/models.rs | 25 +++++++++++++++++----- codex-rs/protocol/src/protocol.rs | 5 +++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index df636d4a6b..4cf1b72d5d 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -67,7 +67,7 @@ fn parses_user_message_with_text_and_two_images() { #[test] fn skips_local_image_label_text() { let image_url = "data:image/png;base64,abc".to_string(); - let label = codex_protocol::models::local_image_open_tag_text(/*label_number*/ 1); + let label = r#""#.to_string(); let user_text = "Please review this image.".to_string(); let item = ResponseItem::Message { diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 87eb03079e..7018274510 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -161,7 +161,9 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu role: "user".to_string(), content: vec![ ContentItem::InputText { - text: codex_protocol::models::local_image_open_tag_text(/*label_number*/ 1), + text: codex_protocol::models::local_image_open_tag_text_with_path( + /*label_number*/ 1, &abs_path, + ), }, ContentItem::InputImage { image_url, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5a08610cd6..d95370c061 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1019,9 +1019,10 @@ pub fn local_image_label_text(label_number: usize) -> String { format!("[Image #{label_number}]") } -pub fn local_image_open_tag_text(label_number: usize) -> String { +pub fn local_image_open_tag_text_with_path(label_number: usize, path: &std::path::Path) -> String { let label = local_image_label_text(label_number); - format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label}{LOCAL_IMAGE_OPEN_TAG_SUFFIX}") + let path = path.display(); + format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label} path=\"{path}\"{LOCAL_IMAGE_OPEN_TAG_SUFFIX}") } pub fn is_local_image_open_tag_text(text: &str) -> bool { @@ -1080,7 +1081,7 @@ pub fn local_image_content_items_with_label_number( let mut items = Vec::with_capacity(3); if let Some(label_number) = label_number { items.push(ContentItem::InputText { - text: local_image_open_tag_text(label_number), + text: local_image_open_tag_text_with_path(label_number, path), }); } items.push(ContentItem::InputImage { @@ -2866,7 +2867,7 @@ mod tests { detail: None, }, UserInput::LocalImage { - path: local_path, + path: local_path.clone(), detail: None, }, ]); @@ -2883,7 +2884,10 @@ mod tests { assert_eq!( content.get(1), Some(&ContentItem::InputText { - text: local_image_open_tag_text(/*label_number*/ 2), + text: local_image_open_tag_text_with_path( + /*label_number*/ 2, + &local_path + ), }) ); assert!(matches!( @@ -2903,6 +2907,17 @@ mod tests { Ok(()) } + #[test] + fn local_image_open_tag_preserves_path() { + assert_eq!( + local_image_open_tag_text_with_path( + /*label_number*/ 1, + std::path::Path::new(r#"/tmp/a&".png"#), + ), + r#".png">"# + ); + } + #[test] fn local_image_user_input_preserves_requested_detail() -> Result<()> { let dir = tempdir()?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2674492e88..91ef624fa7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2190,8 +2190,9 @@ pub struct UserMessageEvent { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub image_details: Vec>, /// Local file paths sourced from `UserInput::LocalImage`. These are kept so - /// the UI can reattach images when editing history, and should not be sent - /// to the model or treated as API-ready URLs. + /// the UI can reattach images when editing history. Local image prompts may + /// include a display form of the path, but these should not be treated as + /// API-ready URLs. #[serde(default)] pub local_images: Vec, /// Detail hints for `local_images`, indexed in parallel. Missing entries From a2ebe07b39823945aaedd4e1acbd70316ca13f1d Mon Sep 17 00:00:00 2001 From: dhruvgupta-oai Date: Thu, 4 Jun 2026 01:25:11 +0530 Subject: [PATCH 006/141] [profile-switcher][rust] -- [1/2] Add app-server account session protocol (#25469) ## Summary Adds the app-server v2 `accountSession/*` protocol used by the Desktop profile switcher and the backend account metadata client needed to populate workspace choices. This is the protocol layer only. The app-server lifecycle and consolidated saved-session storage are split into a follow-up PR. ## Rust Stack 1. This PR 2. [openai/codex#25383](https://github.com/openai/codex/pull/25383) adds app-server session lifecycle behavior and consolidated saved-session storage. ## Validation - Generated app-server schema fixtures are included from the existing generation flow in the lifecycle PR where the routes are registered. - Did not run tests per requested scope. --- .../src/protocol/v2/account.rs | 72 +++++++++++++++ codex-rs/backend-client/src/client.rs | 11 +++ codex-rs/backend-client/src/lib.rs | 2 + codex-rs/backend-client/src/types.rs | 87 +++++++++++++++++++ 4 files changed, 172 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index c2a538982c..83f1bc02b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -138,6 +138,78 @@ pub struct CancelLoginAccountResponse { pub status: CancelLoginAccountStatus, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSessionsAddParams { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub switch_to_added_account: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSessionsListParams { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub refresh_workspace_metadata: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSessionsLogoutParams { + pub session_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSessionsSwitchParams { + pub session_id: String, + pub account_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSessionsResponse { + pub active_session_id: Option, + pub sessions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSession { + pub session_id: String, + pub email: Option, + pub user_id: Option, + pub display_name: Option, + pub image_url: Option, + pub last_used_at: i64, + pub is_active: bool, + pub selected_workspace_account_id: Option, + pub workspaces: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountSessionWorkspace { + pub account_id: String, + pub name: Option, + pub image_url: Option, + pub kind: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum AccountSessionWorkspaceKind { + Personal, + Workspace, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index d9bdc9654e..24d2776590 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,3 +1,4 @@ +use crate::types::AccountsCheckResponse; use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; @@ -302,6 +303,16 @@ impl Client { Ok(Self::rate_limit_snapshots_from_payload(payload)) } + pub async fn get_accounts_check(&self) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + pub async fn send_add_credits_nudge_email( &self, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 250aac3d73..dfd7e816f6 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -4,6 +4,8 @@ pub(crate) mod types; pub use client::AddCreditsNudgeCreditType; pub use client::Client; pub use client::RequestError; +pub use types::AccountEntry; +pub use types::AccountsCheckResponse; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigBundleResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 40daba2ce5..06989241fa 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -17,6 +17,93 @@ use serde::de::Deserializer; use serde_json::Value; use std::collections::HashMap; +#[derive(Clone, Debug)] +pub struct AccountsCheckResponse { + pub accounts: Vec, + pub account_ordering: Vec, + pub default_account_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AccountEntry { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub profile_picture_url: Option, + #[serde(default)] + pub structure: String, +} + +#[derive(Deserialize)] +struct RawAccountsCheckResponse { + #[serde(default)] + accounts: RawAccounts, + #[serde(default)] + account_ordering: Vec, + #[serde(default)] + default_account_id: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RawAccounts { + List(Vec), + Map(HashMap), +} + +impl Default for RawAccounts { + fn default() -> Self { + Self::List(Vec::new()) + } +} + +#[derive(Deserialize)] +struct ChatGptAccountEntry { + account: ChatGptAccountInfo, +} + +#[derive(Deserialize)] +struct ChatGptAccountInfo { + account_id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + profile_picture_url: Option, + #[serde(default)] + structure: String, +} + +impl<'de> Deserialize<'de> for AccountsCheckResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawAccountsCheckResponse::deserialize(deserializer)?; + let accounts = match raw.accounts { + RawAccounts::List(accounts) => accounts, + RawAccounts::Map(mut accounts) => raw + .account_ordering + .iter() + .filter_map(|account_id| { + let account = accounts.remove(account_id)?.account; + Some(AccountEntry { + id: account.account_id?, + name: account.name, + profile_picture_url: account.profile_picture_url, + structure: account.structure, + }) + }) + .collect(), + }; + Ok(Self { + accounts, + account_ordering: raw.account_ordering, + default_account_id: raw.default_account_id, + }) + } +} + /// Hand-rolled models for the Cloud Tasks task-details response. /// The generated OpenAPI models are pretty bad. This is a half-step /// towards hand-rolling them. From d8121f93c8df2e9066e929128886cae2fc81782b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 3 Jun 2026 12:56:54 -0700 Subject: [PATCH 007/141] Fix forked thread name inheritance (#26075) Fixes #25950. ## Why Forking a renamed thread could fall back to the source thread's first-prompt title because the fork path did not preserve the source's explicit name. That meant fork-of-renamed-fork flows could show stale sidebar labels even though the user had renamed the parent. ## What changed `thread/fork` now reads the source thread's distinct `name`, normalizes it, persists it onto materialized forks, and applies it to the returned API thread. Because the source `name` already excludes first-prompt pseudo-titles, forks inherit only an explicit user rename instead of stale generated metadata. --- .../request_processors/thread_processor.rs | 23 ++++- .../app-server/tests/suite/v2/thread_fork.rs | 90 ++++++++++++++----- 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index b26c2e4df3..265832f9b7 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -3173,6 +3173,10 @@ impl ThreadRequestProcessor { .read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true) .await?; let source_thread_id = source_thread.thread_id; + let source_thread_name = source_thread + .name + .as_deref() + .and_then(codex_core::util::normalize_thread_name); let history_items = source_thread .history .as_ref() @@ -3264,6 +3268,21 @@ impl ThreadRequestProcessor { app_server_client_version, ) .await?; + if session_configured.rollout_path.is_some() + && let Some(name) = source_thread_name.clone() + { + self.thread_manager + .update_thread_metadata( + thread_id, + StoreThreadMetadataPatch { + name: Some(Some(name)), + ..Default::default() + }, + /*include_archived*/ true, + ) + .await + .map_err(|err| core_thread_write_error("inherit source thread name", err))?; + } // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( @@ -3291,7 +3310,6 @@ impl ThreadRequestProcessor { ) } else { let config_snapshot = forked_thread.config_snapshot().await; - // forked thread names do not inherit the source thread name let mut thread = build_thread_from_snapshot( thread_id, session_configured.session_id.to_string(), @@ -3309,6 +3327,9 @@ impl ThreadRequestProcessor { } thread }; + if let Some(name) = source_thread_name { + set_thread_name_from_title(&mut thread, name); + } thread.session_id = session_configured.session_id.to_string(); thread.thread_source = forked_thread .config_snapshot() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index afb1ca0ed1..ff9f655630 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -30,9 +30,11 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::ThreadId; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RolloutItem; use codex_rollout::append_rollout_item_to_path; +use codex_rollout::append_thread_name; use codex_rollout::read_session_meta_line; use pretty_assertions::assert_eq; use serde_json::Value; @@ -56,6 +58,29 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +async fn list_threads(mcp: &mut TestAppServer) -> Result { + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(50), + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + to_response::(list_resp) +} + #[tokio::test] async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -224,6 +249,50 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + let source_thread_id = ThreadId::from_string(&conversation_id)?; + let source_name = "Renamed parent thread"; + append_thread_name(codex_home.path(), source_thread_id, source_name).await?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?; + let listed = data + .iter() + .find(|candidate| candidate.id == thread.id) + .expect("thread/list should include the forked thread"); + assert_eq!(listed.name.as_deref(), Some(source_name)); + + Ok(()) +} + #[tokio::test] async fn thread_fork_can_load_source_by_path() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -723,26 +792,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() expected_started_thread.turns.clear(); assert_eq!(started.thread, expected_started_thread); - let list_id = mcp - .send_thread_list_request(ThreadListParams { - cursor: None, - limit: Some(10), - sort_key: None, - sort_direction: None, - model_providers: None, - source_kinds: None, - archived: None, - cwd: None, - use_state_db_only: false, - search_term: None, - }) - .await?; - let list_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(list_id)), - ) - .await??; - let ThreadListResponse { data, .. } = to_response::(list_resp)?; + let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?; assert!( data.iter().all(|candidate| candidate.id != fork_thread_id), "ephemeral forks should not appear in thread/list" From 0eb7e6d79b44b95d4aa6b22759322efcccde3521 Mon Sep 17 00:00:00 2001 From: Won Park Date: Wed, 3 Jun 2026 14:02:55 -0700 Subject: [PATCH 008/141] Restore Windows coverage for code-mode image generation exposure (#25960) ## Summary Restore Windows coverage for standalone image generation in code mode. The previous test executed a V8-backed code-mode cell on Windows CI, where that runtime path is intentionally excluded because it is unreliable. The test was then ignored entirely on Windows, removing useful coverage. This splits the test into two checks: - All platforms verify that `image_gen__imagegen` is exposed to the model when image generation is configured for code mode only. - Non-Windows platforms continue to execute the full V8-backed flow and verify that the nested image-generation call succeeds. ## Verification - `just fmt` - `git diff --check` - `just test -p codex-app-server standalone_image_generation` Result: 3 tests passed, plus the required bench smoke check. --- .../tests/suite/v2/imagegen_extension.rs | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 924c6c58ba..3469bf44c1 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -136,7 +136,50 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Ok(()) } -#[cfg_attr(windows, ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + ImagegenTestMode::CodeModeOnly, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + start_image_generation_turn(&mut mcp).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!( + response_mock + .single_request() + .body_contains_text("image_gen__imagegen") + ); + + Ok(()) +} + +#[cfg(not(windows))] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> { let call_id = "code-mode-image-run-1"; From 2ca3810005f61bb033ccf6c48111e2aa4536b0e4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Jun 2026 14:29:52 -0700 Subject: [PATCH 009/141] [codex] Split Python runtime release workflow (#26226) ## Why Python SDK releases pin an exact `openai-codex-cli-bin` version, so all eight platform runtime wheels must be available on PyPI before the SDK package is built and published. PyPI does not support reusable workflows as Trusted Publishers, which means OIDC-backed publishing must run from each top-level release workflow. ## What changed - add reusable `python-runtime-build.yml` to prepare and upload all eight runtime wheels without publishing - add top-level `python-runtime-release.yml` for manual runtime publication before updating an SDK pin - have `python-sdk-release.yml` publish and verify the prepared runtime wheels from its own top-level trusted job before building the SDK - verify PyPI exposes exactly the expected eight runtime wheels before either release workflow continues ## PyPI configuration - keep the trusted publisher for `.github/workflows/python-sdk-release.yml` with environment `pypi` - add a trusted publisher for `.github/workflows/python-runtime-release.yml` with environment `pypi` - no trusted publisher is needed for `.github/workflows/python-runtime-build.yml` ## Validation - parsed all three workflow YAML files - validated all embedded shell blocks with `bash -n` - no local tests run; relying on online CI --- .github/workflows/python-runtime-build.yml | 121 ++++++++++ .github/workflows/python-runtime-release.yml | 100 ++++++++ .github/workflows/python-sdk-release.yml | 230 +++++++++---------- 3 files changed, 336 insertions(+), 115 deletions(-) create mode 100644 .github/workflows/python-runtime-build.yml create mode 100644 .github/workflows/python-runtime-release.yml diff --git a/.github/workflows/python-runtime-build.yml b/.github/workflows/python-runtime-build.yml new file mode 100644 index 0000000000..1b91ab5692 --- /dev/null +++ b/.github/workflows/python-runtime-build.yml @@ -0,0 +1,121 @@ +name: python-runtime-build + +on: + workflow_call: + inputs: + runtime_version: + description: "Runtime version to build, for example 0.136.0 or 0.136.0a2." + required: true + type: string + +jobs: + build-python-runtime: + if: github.repository == 'openai/codex' + name: build-python-runtime + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate and resolve Python runtime release + id: python_runtime + shell: bash + env: + REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import os + import re + from pathlib import Path + + python_version = os.environ["REQUESTED_RUNTIME_VERSION"] + if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version): + release_version = f"{match.group(1)}-alpha.{match.group(2)}" + elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version): + release_version = python_version + else: + raise SystemExit( + "Python runtime version must be stable or a numbered alpha, " + f"for example 0.136.0 or 0.136.0a2; found {python_version}" + ) + + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + print(f"python_version={python_version}", file=output) + print(f"release_tag=rust-v{release_version}", file=output) + PY + + - name: Download Python runtime release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYTHON_RUNTIME_VERSION: ${{ steps.python_runtime.outputs.python_version }} + RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} + run: | + set -euo pipefail + mkdir -p dist/python-runtime dist/python-runtime-packages + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "openai_codex_cli_bin-${PYTHON_RUNTIME_VERSION}-*.whl" \ + --dir dist/python-runtime + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-package-*-unknown-linux-musl.tar.gz" \ + --dir dist/python-runtime-packages + + shopt -s nullglob + wheels=(dist/python-runtime/*.whl) + if [[ "${#wheels[@]}" -ne 6 ]]; then + echo "Expected 6 Python runtime wheels for ${PYTHON_RUNTIME_VERSION}, found ${#wheels[@]}." + exit 1 + fi + packages=(dist/python-runtime-packages/*.tar.gz) + if [[ "${#packages[@]}" -ne 2 ]]; then + echo "Expected 2 Linux package archives for ${PYTHON_RUNTIME_VERSION}, found ${#packages[@]}." + exit 1 + fi + + - name: Build musllinux Python runtime wheels + env: + RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} + run: | + set -euo pipefail + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + while read -r target platform_tag; do + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${target}-${platform_tag}" + python3 sdk/python/scripts/update_sdk_artifacts.py \ + stage-runtime \ + "$stage_dir" \ + "dist/python-runtime-packages/codex-package-${target}.tar.gz" \ + --codex-version "$RELEASE_TAG" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build \ + --wheel \ + --outdir dist/python-runtime \ + "$stage_dir" + done <<'EOF' + aarch64-unknown-linux-musl musllinux_1_1_aarch64 + x86_64-unknown-linux-musl musllinux_1_1_x86_64 + EOF + + shopt -s nullglob + wheels=(dist/python-runtime/*.whl) + if [[ "${#wheels[@]}" -ne 8 ]]; then + echo "Expected 8 Python runtime wheels, found ${#wheels[@]}." + exit 1 + fi + ls -lh dist/python-runtime + + - name: Upload Python runtime wheels + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheels + path: dist/python-runtime/* + if-no-files-found: error diff --git a/.github/workflows/python-runtime-release.yml b/.github/workflows/python-runtime-release.yml new file mode 100644 index 0000000000..4068786f31 --- /dev/null +++ b/.github/workflows/python-runtime-release.yml @@ -0,0 +1,100 @@ +name: python-runtime-release + +on: + workflow_dispatch: + inputs: + runtime_version: + description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2." + required: true + type: string + +concurrency: + group: python-runtime-release-${{ inputs.runtime_version }} + cancel-in-progress: false + +jobs: + prepare-python-runtime: + name: prepare-python-runtime + permissions: + contents: read + uses: ./.github/workflows/python-runtime-build.yml + with: + runtime_version: ${{ inputs.runtime_version }} + + # PyPI must trust this top-level workflow for manual runtime publication. + publish-python-runtime: + if: github.repository == 'openai/codex' + name: publish-python-runtime + needs: prepare-python-runtime + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. + + steps: + - name: Download Python runtime wheels + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-runtime-wheels + path: dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-runtime + skip-existing: true + + - name: Verify Python runtime wheels are available on PyPI + env: + PYTHON_RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + set -euo pipefail + for attempt in {1..30}; do + if python3 - <<'PY' + import json + import os + import urllib.error + import urllib.request + + version = os.environ["PYTHON_RUNTIME_VERSION"] + tags = { + "macosx_10_9_x86_64", + "macosx_11_0_arm64", + "manylinux_2_17_aarch64", + "manylinux_2_17_x86_64", + "musllinux_1_1_aarch64", + "musllinux_1_1_x86_64", + "win_amd64", + "win_arm64", + } + expected = { + f"openai_codex_cli_bin-{version}-py3-none-{tag}.whl" + for tag in tags + } + + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/openai-codex-cli-bin/{version}/json", + timeout=30, + ) as response: + payload = json.load(response) + except urllib.error.URLError as error: + print(f"Could not read runtime {version} from PyPI: {error}.") + raise SystemExit(1) from error + + actual = {file["filename"] for file in payload["urls"]} + if actual != expected: + print(f"Missing runtime wheels: {sorted(expected - actual)}") + print(f"Unexpected runtime files: {sorted(actual - expected)}") + raise SystemExit(1) + PY + then + exit 0 + fi + echo "Runtime wheels are not available on PyPI yet; retrying (${attempt}/30)." + sleep 10 + done + + echo "Runtime wheels did not become available on PyPI." + exit 1 diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 2db8b4b53e..3ca930daa9 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -4,29 +4,21 @@ on: push: tags: - "python-v*" - workflow_dispatch: - inputs: - runtime_version: - description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2." - required: true - type: string concurrency: group: ${{ github.workflow }} cancel-in-progress: false jobs: - # Publish the platform-specific Python runtime wheels before building the SDK - # package that pins them, or explicitly before updating the SDK runtime pin. - # PyPI project configuration must trust this workflow and job for publishing. - publish-python-runtime: + resolve-python-release: if: github.repository == 'openai/codex' - name: publish-python-runtime + name: resolve-python-release runs-on: ubuntu-latest - environment: pypi permissions: contents: read - id-token: write # Required for PyPI trusted publishing. + outputs: + runtime_version: ${{ steps.python_release.outputs.runtime_version }} + sdk_version: ${{ steps.python_release.outputs.sdk_version }} steps: - name: Checkout repository @@ -34,11 +26,9 @@ jobs: with: persist-credentials: false - - name: Validate SDK tag and resolve Python runtime release - id: python_runtime + - name: Validate SDK tag and resolve pinned runtime + id: python_release shell: bash - env: - REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }} run: | set -euo pipefail python3 - <<'PY' @@ -47,107 +37,60 @@ jobs: import tomllib from pathlib import Path - event_name = os.environ["GITHUB_EVENT_NAME"] - if event_name == "workflow_dispatch": - python_version = os.environ["REQUESTED_RUNTIME_VERSION"] - elif event_name == "push": - sdk_version = os.environ["GITHUB_REF_NAME"].removeprefix("python-v") - if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+b[0-9]+", sdk_version): - raise SystemExit( - "Python SDK release tags must identify a beta release, " - "for example python-v0.1.0b1." - ) - - pyproject = tomllib.loads(Path("sdk/python/pyproject.toml").read_text()) - prefix = "openai-codex-cli-bin==" - versions = [ - dependency.removeprefix(prefix) - for dependency in pyproject["project"]["dependencies"] - if dependency.startswith(prefix) - ] - if len(versions) != 1: - raise SystemExit(f"Expected exactly one pinned {prefix} dependency, found {versions}") - python_version = versions[0] - else: - raise SystemExit(f"Unsupported workflow event: {event_name}") - - if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version): - release_version = f"{match.group(1)}-alpha.{match.group(2)}" - elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version): - release_version = python_version - else: + sdk_version = os.environ["GITHUB_REF_NAME"].removeprefix("python-v") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+b[0-9]+", sdk_version): raise SystemExit( - "Python runtime version must be stable or a numbered alpha, " - f"for example 0.136.0 or 0.136.0a2; found {python_version}" + "Python SDK release tags must identify a beta release, " + "for example python-v0.1.0b1." + ) + + pyproject = tomllib.loads(Path("sdk/python/pyproject.toml").read_text()) + prefix = "openai-codex-cli-bin==" + runtime_versions = [ + dependency.removeprefix(prefix) + for dependency in pyproject["project"]["dependencies"] + if dependency.startswith(prefix) + ] + if len(runtime_versions) != 1: + raise SystemExit( + f"Expected exactly one pinned {prefix} dependency, found {runtime_versions}" ) with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - print(f"python_version={python_version}", file=output) - print(f"release_tag=rust-v{release_version}", file=output) + print(f"runtime_version={runtime_versions[0]}", file=output) + print(f"sdk_version={sdk_version}", file=output) PY - - name: Download Python runtime release artifacts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PYTHON_RUNTIME_VERSION: ${{ steps.python_runtime.outputs.python_version }} - RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} - run: | - set -euo pipefail - mkdir -p dist/python-runtime dist/python-runtime-packages - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${PYTHON_RUNTIME_VERSION}-*.whl" \ - --dir dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "codex-package-*-unknown-linux-musl.tar.gz" \ - --dir dist/python-runtime-packages + prepare-python-runtime: + name: prepare-python-runtime + needs: resolve-python-release + permissions: + contents: read + uses: ./.github/workflows/python-runtime-build.yml + with: + runtime_version: ${{ needs.resolve-python-release.outputs.runtime_version }} - shopt -s nullglob - wheels=(dist/python-runtime/*.whl) - if [[ "${#wheels[@]}" -ne 6 ]]; then - echo "Expected 6 Python runtime wheels for ${PYTHON_RUNTIME_VERSION}, found ${#wheels[@]}." - exit 1 - fi - packages=(dist/python-runtime-packages/*.tar.gz) - if [[ "${#packages[@]}" -ne 2 ]]; then - echo "Expected 2 Linux package archives for ${PYTHON_RUNTIME_VERSION}, found ${#packages[@]}." - exit 1 - fi + # Always publish the exact pinned runtime from this top-level workflow before + # building the SDK package. PyPI does not support reusable workflows as + # Trusted Publishers. + publish-python-runtime: + if: github.repository == 'openai/codex' + name: publish-python-runtime + needs: + - prepare-python-runtime + - resolve-python-release + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. - - name: Build musllinux Python runtime wheels - env: - RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} - run: | - set -euo pipefail - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - while read -r target platform_tag; do - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${target}-${platform_tag}" - python3 sdk/python/scripts/update_sdk_artifacts.py \ - stage-runtime \ - "$stage_dir" \ - "dist/python-runtime-packages/codex-package-${target}.tar.gz" \ - --codex-version "$RELEASE_TAG" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build \ - --wheel \ - --outdir dist/python-runtime \ - "$stage_dir" - done <<'EOF' - aarch64-unknown-linux-musl musllinux_1_1_aarch64 - x86_64-unknown-linux-musl musllinux_1_1_x86_64 - EOF - - shopt -s nullglob - wheels=(dist/python-runtime/*.whl) - if [[ "${#wheels[@]}" -ne 8 ]]; then - echo "Expected 8 Python runtime wheels, found ${#wheels[@]}." - exit 1 - fi - ls -lh dist/python-runtime + steps: + - name: Download Python runtime wheels + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-runtime-wheels + path: dist/python-runtime - name: Publish Python runtime wheels to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 @@ -155,10 +98,66 @@ jobs: packages-dir: dist/python-runtime skip-existing: true + - name: Verify Python runtime wheels are available on PyPI + env: + PYTHON_RUNTIME_VERSION: ${{ needs.resolve-python-release.outputs.runtime_version }} + run: | + set -euo pipefail + for attempt in {1..30}; do + if python3 - <<'PY' + import json + import os + import urllib.error + import urllib.request + + version = os.environ["PYTHON_RUNTIME_VERSION"] + tags = { + "macosx_10_9_x86_64", + "macosx_11_0_arm64", + "manylinux_2_17_aarch64", + "manylinux_2_17_x86_64", + "musllinux_1_1_aarch64", + "musllinux_1_1_x86_64", + "win_amd64", + "win_arm64", + } + expected = { + f"openai_codex_cli_bin-{version}-py3-none-{tag}.whl" + for tag in tags + } + + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/openai-codex-cli-bin/{version}/json", + timeout=30, + ) as response: + payload = json.load(response) + except urllib.error.URLError as error: + print(f"Could not read runtime {version} from PyPI: {error}.") + raise SystemExit(1) from error + + actual = {file["filename"] for file in payload["urls"]} + if actual != expected: + print(f"Missing runtime wheels: {sorted(expected - actual)}") + print(f"Unexpected runtime files: {sorted(actual - expected)}") + raise SystemExit(1) + PY + then + exit 0 + fi + echo "Runtime wheels are not available on PyPI yet; retrying (${attempt}/30)." + sleep 10 + done + + echo "Runtime wheels did not become available on PyPI." + exit 1 + build-python-sdk: - if: github.event_name == 'push' && github.repository == 'openai/codex' + if: github.repository == 'openai/codex' name: build-python-sdk - needs: publish-python-runtime + needs: + - publish-python-runtime + - resolve-python-release runs-on: ubuntu-latest permissions: contents: read @@ -169,19 +168,20 @@ jobs: with: persist-credentials: false - - name: Validate tag and build Python SDK package + - name: Build Python SDK package shell: bash + env: + SDK_VERSION: ${{ needs.resolve-python-release.outputs.sdk_version }} run: | set -euo pipefail - sdk_version="${GITHUB_REF_NAME#python-v}" # Build in a glibc Linux image so release type generation installs # the pinned manylinux runtime wheel. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ -e UV_LINK_MODE=copy \ - -e SDK_VERSION="${sdk_version}" \ + -e SDK_VERSION \ -e SDK_STAGE_DIR="${RUNNER_TEMP}/openai-codex" \ -e SDK_DIST_DIR="${GITHUB_WORKSPACE}/dist/python-sdk" \ -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ From 11bceb8f8bfdd8723da10bdcaa767a19d92a2a57 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 00:24:40 +0200 Subject: [PATCH 010/141] feat: catalog multi-agent v2 config (#26254) ## Why Model metadata can now select multi-agent v2 even when a user has not enabled `features.multi_agent_v2` in their config. Some existing configs still set the legacy `agents.max_threads` knob for v1 multi-agent behavior, so treating every v2 runtime as incompatible with `agents.max_threads` would break users whose only v2 signal came from the model catalog. The incompatible configuration is specifically enabling `features.multi_agent_v2` while also setting `agents.max_threads`. Catalog-forced v2 should use the v2 concurrency setting and ignore the legacy v1 cap instead of rejecting the config. ## What changed - Split config validation from runtime concurrency calculation: `effective_agent_max_threads` now just returns the effective cap for the resolved multi-agent runtime. - Added explicit validation for `features.multi_agent_v2` + `agents.max_threads` at session startup. - Preserved catalog-selected v2 behavior when `features.multi_agent_v2` is disabled, so existing configs with `agents.max_threads` keep starting. - Updated model-runtime selector coverage so a catalog v2 model still exposes v2 tools even when `agents.max_threads` is set and the config flag is disabled. ## Validation - `cargo check -p codex-core --lib` - `just test -p codex-core --lib -E "test(multi_agent_v2_feature_rejects_agents_max_threads) | test(catalog_v2_allows_agents_max_threads_when_feature_disabled)"` --- codex-rs/core/src/agent/control.rs | 8 +--- codex-rs/core/src/config/config_tests.rs | 44 ++++++++++++++++--- codex-rs/core/src/config/mod.rs | 33 +++++++------- codex-rs/core/src/session/mod.rs | 7 +-- .../core/src/tools/handlers/agent_jobs.rs | 5 +-- .../tests/suite/model_runtime_selectors.rs | 1 + 6 files changed, 62 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 821611e60e..549852e360 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -231,9 +231,7 @@ impl AgentControl { &config, ) .await; - let agent_max_threads = config - .effective_agent_max_threads(multi_agent_version) - .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; let inheritance = SpawnAgentThreadInheritance { shell_snapshot: self @@ -632,9 +630,7 @@ impl AgentControl { &config, ) .await; - let agent_max_threads = config - .effective_agent_max_threads(multi_agent_version) - .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 87f00fed86..dac8286749 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9836,7 +9836,7 @@ non_code_mode_only = true assert_eq!( ( config.agent_max_threads, - config.effective_agent_max_threads(MultiAgentVersion::V2)? + config.effective_agent_max_threads(MultiAgentVersion::V2) ), (None, Some(4)) ); @@ -9886,7 +9886,7 @@ enabled = true assert_eq!( ( config.agent_max_threads, - config.effective_agent_max_threads(MultiAgentVersion::V2)? + config.effective_agent_max_threads(MultiAgentVersion::V2) ), (None, Some(3)) ); @@ -9945,7 +9945,7 @@ subagent_usage_hint_text = "" } #[tokio::test] -async fn multi_agent_v2_rejects_agents_max_threads() -> std::io::Result<()> { +async fn multi_agent_v2_feature_rejects_agents_max_threads() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -9963,13 +9963,45 @@ max_threads = 3 .build() .await?; let err = config - .effective_agent_max_threads(MultiAgentVersion::V2) + .validate_multi_agent_v2_config() .expect_err("agents.max_threads should conflict with multi_agent_v2"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), - "agents.max_threads cannot be set when the multi-agent runtime is v2" + "agents.max_threads cannot be set when features.multi_agent_v2 is enabled" + ); + assert_eq!( + config.effective_agent_max_threads(MultiAgentVersion::V2), + Some(3) + ); + + Ok(()) +} + +#[tokio::test] +async fn catalog_v2_allows_agents_max_threads_when_feature_disabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = false + +[agents] +max_threads = 3 +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + config.validate_multi_agent_v2_config()?; + assert_eq!( + config.effective_agent_max_threads(MultiAgentVersion::V2), + Some(3) ); Ok(()) @@ -10231,7 +10263,7 @@ max_concurrent_threads_per_session = 1 assert_eq!( ( config.agent_max_threads, - config.effective_agent_max_threads(MultiAgentVersion::V2)? + config.effective_agent_max_threads(MultiAgentVersion::V2) ), (None, Some(0)) ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 28e97c36a4..5b79672b81 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1286,26 +1286,29 @@ impl Config { } } + pub(crate) fn validate_multi_agent_v2_config(&self) -> std::io::Result<()> { + if self.features.enabled(Feature::MultiAgentV2) && self.agent_max_threads.is_some() { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads cannot be set when features.multi_agent_v2 is enabled", + )) + } else { + Ok(()) + } + } + pub(crate) fn effective_agent_max_threads( &self, multi_agent_version: MultiAgentVersion, - ) -> std::io::Result> { + ) -> Option { match multi_agent_version { - MultiAgentVersion::V2 => { - if self.agent_max_threads.is_some() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads cannot be set when the multi-agent runtime is v2", - )); - } - Ok(Some( - self.multi_agent_v2 - .max_concurrent_threads_per_session - .saturating_sub(1), - )) - } + MultiAgentVersion::V2 => Some( + self.multi_agent_v2 + .max_concurrent_threads_per_session + .saturating_sub(1), + ), MultiAgentVersion::Disabled | MultiAgentVersion::V1 => { - Ok(self.agent_max_threads.or(DEFAULT_AGENT_MAX_THREADS)) + self.agent_max_threads.or(DEFAULT_AGENT_MAX_THREADS) } } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8ce16de740..3ea33a6bde 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -557,11 +557,8 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let startup_multi_agent_version = multi_agent_version - .or(model_info.multi_agent_version) - .unwrap_or_else(|| config.multi_agent_version_from_features()); - let _ = config - .effective_agent_max_threads(startup_multi_agent_version) + config + .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; let base_instructions = config .base_instructions diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 25c8f9c4ca..360744973c 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -116,10 +116,7 @@ async fn build_runner_options( "multi-agent runtime is disabled; this session cannot spawn workers".to_string(), )); } - let agent_max_threads = turn - .config - .effective_agent_max_threads(multi_agent_version) - .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + let agent_max_threads = turn.config.effective_agent_max_threads(multi_agent_version); if agent_max_threads == Some(0) { return Err(FunctionCallError::RespondToModel( "agent thread limit reached; this session cannot spawn more subagents".to_string(), diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index c17121b521..15ed519af7 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -190,6 +190,7 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { let mut v2_model = remote_model("test-multi-agent-v2"); v2_model.multi_agent_version = Some(MultiAgentVersion::V2); let v2_body = response_body_for_remote_model(v2_model, |config| { + config.agent_max_threads = Some(3); config .features .enable(Feature::Collab) From 4231472c03e6c6e3374b1c19ef096ee28476f9dd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 3 Jun 2026 15:25:50 -0700 Subject: [PATCH 011/141] Rewrite oversized tool outputs during remote compaction (#26251) ## Why When trying to fit history under compaction limit rewrite output items instead of removing them entirely. Otherwise we're breaking incrementality in relation to the previous response. --- codex-rs/core/src/compact_remote.rs | 92 ++++-- codex-rs/core/src/compact_remote_v2.rs | 8 +- codex-rs/core/src/context_manager/history.rs | 19 -- .../core/src/context_manager/history_tests.rs | 22 -- codex-rs/core/src/context_manager/mod.rs | 1 - codex-rs/core/tests/suite/compact_remote.rs | 277 ++++++++++++++++-- 6 files changed, 330 insertions(+), 89 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c2ef417b9b..cbac86fb91 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -9,7 +9,6 @@ use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; -use crate::context_manager::is_codex_generated_item; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; @@ -28,6 +27,8 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::BaseInstructions; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; @@ -38,6 +39,9 @@ use tokio_util::sync::CancellationToken; use tracing::error; use tracing::info; +const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = + "Output exceeded the available model context and was truncated"; + pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, @@ -168,21 +172,21 @@ async fn run_remote_compact_task_inner_impl( .await; let mut history = sess.clone_history().await; let base_instructions = sess.get_base_instructions().await; - let deleted_items = trim_function_call_history_to_fit_context_window( + let rewritten_outputs = trim_function_call_history_to_fit_context_window( &mut history, turn_context.as_ref(), &base_instructions, ); - if deleted_items > 0 { + if rewritten_outputs > 0 { info!( turn_id = %turn_context.sub_id, - deleted_items, - "trimmed history items before remote compaction" + rewritten_outputs, + "rewrote history outputs before remote compaction" ); } - // This is the history selected for remote compaction, after any trimming required to fit the - // compact endpoint. The checkpoint below records it separately from the next sampling request, - // whose prompt will repeat current developer/context prefix items. + // This is the history selected for remote compaction, after any output rewriting required to + // fit the compact endpoint. The checkpoint below records it separately from the next sampling + // request, whose prompt will repeat current developer/context prefix items. let trace_input_history = history.raw_items().to_vec(); let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); let tool_router = built_tools( @@ -379,26 +383,68 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( turn_context: &TurnContext, base_instructions: &BaseInstructions, ) -> usize { - let mut deleted_items = 0usize; let Some(context_window) = turn_context.model_context_window() else { - return deleted_items; + return 0; }; + let mut rewritten_outputs = 0usize; + let item_count = history.raw_items().len(); - while history - .estimate_token_count_with_base_instructions(base_instructions) - .is_some_and(|estimated_tokens| estimated_tokens > context_window) - { - let Some(last_item) = history.raw_items().last() else { + for index in (0..item_count).rev() { + if history + .estimate_token_count_with_base_instructions(base_instructions) + .is_none_or(|estimated_tokens| estimated_tokens <= context_window) + { + break; + } + let Some(rewritten_item) = history + .raw_items() + .get(index) + .and_then(rewritten_output_for_context_window) + else { break; }; - if !is_codex_generated_item(last_item) { - break; - } - if !history.remove_last_item() { - break; - } - deleted_items += 1; + let mut items = history.raw_items().to_vec(); + items[index] = rewritten_item; + history.replace(items); + rewritten_outputs += 1; } - deleted_items + rewritten_outputs +} + +fn rewritten_output_for_context_window(item: &ResponseItem) -> Option { + Some(match item { + ResponseItem::FunctionCallOutput { call_id, output } => ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: truncated_output_payload(output), + }, + ResponseItem::CustomToolCallOutput { + call_id, + name, + output, + } => ResponseItem::CustomToolCallOutput { + call_id: call_id.clone(), + name: name.clone(), + output: truncated_output_payload(output), + }, + ResponseItem::ToolSearchOutput { + call_id, + status, + execution, + .. + } => ResponseItem::ToolSearchOutput { + call_id: call_id.clone(), + status: status.clone(), + execution: execution.clone(), + tools: Vec::new(), + }, + _ => return None, + }) +} + +fn truncated_output_payload(output: &FunctionCallOutputPayload) -> FunctionCallOutputPayload { + FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), + success: output.success, + } } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index eb10eb211d..77ef1629c3 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -185,16 +185,16 @@ async fn run_remote_compact_task_inner_impl( let mut history = sess.clone_history().await; let base_instructions = sess.get_base_instructions().await; - let deleted_items = trim_function_call_history_to_fit_context_window( + let rewritten_outputs = trim_function_call_history_to_fit_context_window( &mut history, turn_context.as_ref(), &base_instructions, ); - if deleted_items > 0 { + if rewritten_outputs > 0 { info!( turn_id = %turn_context.sub_id, - deleted_items, - "trimmed history items before remote compaction v2" + rewritten_outputs, + "rewrote history outputs before remote compaction v2" ); } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index e5f6e4132d..0be9bbc18e 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -174,16 +174,6 @@ impl ContextManager { } } - pub(crate) fn remove_last_item(&mut self) -> bool { - if let Some(removed) = self.items.pop() { - normalize::remove_corresponding_for(&mut self.items, &removed); - self.history_version = self.history_version.saturating_add(1); - true - } else { - false - } - } - pub(crate) fn replace(&mut self, items: Vec) { self.items = items; self.history_version = self.history_version.saturating_add(1); @@ -734,15 +724,6 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { } } -pub(crate) fn is_codex_generated_item(item: &ResponseItem) -> bool { - matches!( - item, - ResponseItem::FunctionCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::CustomToolCallOutput { .. } - ) || matches!(item, ResponseItem::Message { role, .. } if role == "developer") -} - pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 7ee22be44a..bb22749cb3 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -651,28 +651,6 @@ fn remove_first_item_removes_matching_call_for_output() { assert_eq!(h.raw_items(), vec![]); } -#[test] -fn remove_last_item_removes_matching_call_for_output() { - let items = vec![ - user_msg("before tool call"), - ResponseItem::FunctionCall { - id: None, - name: "do_it".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-delete-last".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-delete-last".to_string(), - output: FunctionCallOutputPayload::from_text("ok".to_string()), - }, - ]; - let mut h = create_history_with_items(items); - - assert!(h.remove_last_item()); - assert_eq!(h.raw_items(), vec![user_msg("before tool call")]); -} - #[test] fn replace_last_turn_images_replaces_tool_output_images() { let items = vec![ diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 9dd85d1ed9..2295c49df3 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -5,6 +5,5 @@ pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; pub(crate) use history::estimate_response_item_model_visible_bytes; -pub(crate) use history::is_codex_generated_item; pub(crate) use history::is_user_turn_boundary; pub(crate) use history::truncate_function_output_payload; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index f6a87b6c7d..ebb78a0176 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -44,6 +45,9 @@ use serde_json::json; use tokio::time::Duration; use wiremock::ResponseTemplate; +const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = + "Output exceeded the available model context and was truncated"; + fn approx_token_count(text: &str) -> i64 { i64::try_from(text.len().saturating_add(3) / 4).unwrap_or(i64::MAX) } @@ -1449,22 +1453,146 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R "expected compact request to keep the older function call/result pair" ); assert!( - !compact_request.has_function_call(trimmed_call_id) - && compact_request - .function_call_output_text(trimmed_call_id) - .is_none(), - "expected compact request to drop the trailing function call/result pair past the boundary" + compact_request.has_function_call(trimmed_call_id), + "expected compact request to retain the trailing function call" + ); + assert_eq!( + compact_request.function_call_output_text(trimmed_call_id), + Some(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), + "expected compact request to rewrite the trailing function call output past the boundary" ); assert_eq!( compact_request.inputs_of_type("function_call").len(), - 1, - "expected exactly one function call after trimming" + 2, + "expected both function calls after rewriting the trailing output" ); assert_eq!( compact_request.inputs_of_type("function_call_output").len(), - 1, - "expected exactly one function call output after trimming" + 2, + "expected both function call outputs after rewriting the trailing output" + ); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Result<()> { + skip_if_no_network!(Ok(())); + + let first_user_message = "turn with retained shell call"; + let second_user_message = "turn with parallel shell calls"; + let retained_call_id = "retained-call"; + let first_trimmed_call_id = "first-trimmed-call"; + let second_trimmed_call_id = "second-trimmed-call"; + let retained_command = "echo retained-shell-output"; + let first_trimmed_command = "yes x | head -n 3000"; + let second_trimmed_command = "yes y | head -n 3000"; + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_context_window = Some(2_000); + config.model_auto_compact_token_limit = Some(200_000); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + responses::mount_sse_sequence( + harness.server(), + vec![ + sse(vec![ + responses::ev_shell_command_call(retained_call_id, retained_command), + responses::ev_completed("retained-call-response"), + ]), + sse(vec![ + responses::ev_assistant_message("retained-assistant", "retained complete"), + responses::ev_completed("retained-final-response"), + ]), + sse(vec![ + responses::ev_shell_command_call(first_trimmed_call_id, first_trimmed_command), + responses::ev_shell_command_call(second_trimmed_call_id, second_trimmed_command), + responses::ev_completed("parallel-call-response"), + ]), + ], + ) + .await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let compact_mock = responses::mount_compact_user_history_with_summary_once( + harness.server(), + "REMOTE_COMPACT_SUMMARY", + ) + .await; + + codex.submit(Op::Compact).await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let compact_request = compact_mock.single_request(); + assert!( + compact_request.has_function_call(retained_call_id) + && compact_request + .function_call_output_text(retained_call_id) + .is_some(), + "expected compact request to keep the older function call/result pair" + ); + assert!( + compact_request.has_function_call(first_trimmed_call_id) + && compact_request.has_function_call(second_trimmed_call_id), + "expected compact request to retain both trailing parallel function calls" + ); + assert_eq!( + compact_request.function_call_output_text(first_trimmed_call_id), + Some(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), + "expected compact request to rewrite the first trailing function call output" + ); + assert_eq!( + compact_request.function_call_output_text(second_trimmed_call_id), + Some(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), + "expected compact request to rewrite the second trailing function call output" + ); + + assert_eq!( + compact_request.inputs_of_type("function_call").len(), + 3, + "expected all function calls after rewriting trailing outputs" + ); + assert_eq!( + compact_request.inputs_of_type("function_call_output").len(), + 3, + "expected all function call outputs after rewriting trailing outputs" ); Ok(()) @@ -1600,22 +1728,126 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() "expected compact request to keep the older function call/result pair" ); assert!( - !compact_request.has_function_call(trimmed_call_id) - && compact_request - .function_call_output_text(trimmed_call_id) - .is_none(), - "expected compact request to drop the trailing function call/result pair past the boundary" + compact_request.has_function_call(trimmed_call_id), + "expected compact request to retain the trailing function call" + ); + assert_eq!( + compact_request.function_call_output_text(trimmed_call_id), + Some(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), + "expected compact request to rewrite the trailing function call output past the boundary" ); assert_eq!( compact_request.inputs_of_type("function_call").len(), - 1, - "expected exactly one function call after trimming" + 2, + "expected both function calls after rewriting the trailing output" ); assert_eq!( compact_request.inputs_of_type("function_call_output").len(), - 1, - "expected exactly one function call output after trimming" + 2, + "expected both function call outputs after rewriting the trailing output" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let search_call_id = "tool-search-1"; + let tool_name = "oversized_dynamic_tool"; + let tool_description = format!( + "Oversized deferred tool for remote compaction. {}", + "x".repeat(20_000) + ); + let _responses_mock = mount_sse_once( + &server, + sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_tool_search_call( + search_call_id, + &json!({ + "query": "oversized deferred tool", + "limit": 8, + }), + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let input_schema = json!({ + "type": "object", + "properties": { + "mode": { "type": "string" }, + }, + "required": ["mode"], + "additionalProperties": false, + }); + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: tool_name.to_string(), + description: tool_description, + input_schema, + defer_loading: true, + }; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + configure_search_capable_model(config); + config.model_context_window = Some(2_000); + }); + let mut test = builder.build(&server).await?; + let new_thread = test + .thread_manager + .start_thread_with_tools(test.config.clone(), vec![dynamic_tool]) + .await?; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + let codex = test.codex.clone(); + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "Find the oversized deferred tool".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_turn_complete(&codex).await; + + let compact_mock = + responses::mount_compact_user_history_with_summary_once(&server, "REMOTE_COMPACT_SUMMARY") + .await; + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + let compact_request = compact_mock.single_request(); + let compact_tools = compact_request + .tool_search_output(search_call_id) + .get("tools") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert!( + compact_request + .inputs_of_type("tool_search_output") + .iter() + .any(|item| item.get("call_id").and_then(Value::as_str) == Some(search_call_id)), + "expected compact request to retain the tool_search_output item" + ); + assert!( + compact_tools.is_empty(), + "expected compact request to rewrite trailing tool_search output to an empty tools array" ); Ok(()) @@ -1936,8 +2168,13 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result "expected remote compact request to preserve older function call history" ); assert!( - !override_compact_request.has_function_call(override_trailing_call_id), - "expected remote compact request to trim trailing function call history with override instructions" + override_compact_request.has_function_call(override_trailing_call_id), + "expected remote compact request to preserve trailing function call history with override instructions" + ); + assert_eq!( + override_compact_request.function_call_output_text(override_trailing_call_id), + Some(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), + "expected remote compact request to rewrite trailing function call output with override instructions" ); Ok(()) From 14272b21e95ed9a54fae21c27d33d3771a9ee30f Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 3 Jun 2026 15:29:57 -0700 Subject: [PATCH 012/141] codex-pr-body: avoid confidential references (#26260) ## Why PR descriptions can be visible outside the context used to generate them. In #23710, a generated description referenced an internal document, showing that the skill needs an explicit guardrail against exposing confidential context. ## What changed - Updated the `codex-pr-body` guidance to prohibit confidential references, including codenames and OpenAI-internal URLs. --- .codex/skills/codex-pr-body/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.codex/skills/codex-pr-body/SKILL.md b/.codex/skills/codex-pr-body/SKILL.md index 76b37b8750..332ba0a268 100644 --- a/.codex/skills/codex-pr-body/SKILL.md +++ b/.codex/skills/codex-pr-body/SKILL.md @@ -19,6 +19,8 @@ Limit discussion to the _net change_ of the commit. It is generally frowned upon Avoid references to absolute paths on my local disk. When talking about a path that is within the repository, simply use the repo-relative path. +Avoid references to confidential information including but not limited to codenames or OpenAI-internal URLs. + It is generally helpful to discuss how the change was verified. That said, it is unnecessary to mention things that CI checks automatically, e.g., do not include "ran `just fmt`" as part of the test plan. Though identifying the new tests that were purposely introduced to verify the new behavior introduced by the pull request is often appropriate. Make use of Markdown to format the pull request professionally. Ensure "code things" appear in single backticks when referenced inline. Fenced code blocks are useful when referencing code or showing a shell transcript. Also, make use of GitHub permalinks when citing existing pieces of code that are relevant to the change. From 0ed2735d195d149766b242d972f95f3173ebc984 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 3 Jun 2026 15:33:34 -0700 Subject: [PATCH 013/141] Use Windows setup marker as completion signal (#26074) # Why When an organization requires the elevated Windows sandbox, Codex launches an elevated helper to provision users, configure firewall and ACL rules, and lock persistent sandbox directories. We observed that closing the helper after setup started could leave the machine partially initialized while the TUI still announced **Sandbox ready**. Model-only turns continued to work, but the first shell command retried setup and failed with Windows cancellation error `1223`. This was not an enforcement bypass; command execution continued to fail closed. The issue was a false readiness signal: `setup_marker.json` was written during user provisioning, before the remaining setup stages had completed. # What Treat `setup_marker.json` as the commit record for Windows sandbox setup: 1. Before full or provisioning setup begins, remove the existing marker and create the final marker path with a protected ACL. 2. Keep the marker empty and therefore invalid while setup is in progress. Sandbox users cannot read, modify, or replace it. 3. Run every synchronous setup stage. 4. After setup succeeds, write the valid marker contents without changing its ACL. 5. After the helper exits successfully, verify the existing readiness check before enabling the sandbox. If setup is canceled or fails, the marker remains invalid and Codex reports setup as incomplete instead of announcing readiness. Refresh-only and read-ACL-only helper runs continue to leave the marker untouched. The setup version remains `5` to avoid forcing all existing Windows users through elevated setup again. # Verification - Added coverage confirming sandbox users cannot read or modify the setup marker after elevated setup. - Added coverage confirming a successful helper exit without complete setup artifacts is rejected. - Ran `just test -p codex-windows-sandbox`. --- codex-rs/core/tests/suite/windows_sandbox.rs | 32 +++- .../src/bin/setup_main/win.rs | 18 ++- .../src/bin/setup_main/win/sandbox_users.rs | 145 ++++++++++++++---- codex-rs/windows-sandbox-rs/src/setup.rs | 29 ++++ .../windows-sandbox-rs/src/setup_error.rs | 5 +- 5 files changed, 195 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index 120381e67a..74d7f6a23b 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -3,6 +3,7 @@ use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::sandboxing::SandboxPermissions; +use codex_core::windows_sandbox::sandbox_setup_is_complete; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::PermissionProfile; @@ -197,7 +198,7 @@ async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> a #[tokio::test] #[serial(codex_home)] -async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow::Result<()> { +async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyhow::Result<()> { let codex_home = codex_home_for_windows_sandbox_test("windows-elevated-deny-read-codex-home")?; let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str()); stage_windows_sandbox_helpers()?; @@ -206,6 +207,7 @@ async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow:: let glob_secret = cwd.join("secret.env"); let exact_secret = cwd.join("exact-secret.txt"); let public = cwd.join("public.txt"); + let setup_marker = codex_home.path().join(".sandbox").join("setup_marker.json"); std::fs::write(&glob_secret, "glob secret\n")?; std::fs::write(&exact_secret, "exact secret\n")?; std::fs::write(&public, "public ok\n")?; @@ -242,7 +244,6 @@ async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow:: let ExecToolCallOutput { exit_code, stdout, - stderr, .. } = process_exec_tool_call( ExecParams { @@ -250,7 +251,11 @@ async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow:: "cmd.exe".to_string(), "/D".to_string(), "/C".to_string(), - "(type secret.env 1>NUL 2>NUL && echo GLOB-READ || echo GLOB-DENIED) & (type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & type public.txt".to_string(), + format!( + "(type secret.env 1>NUL 2>NUL && echo GLOB-READ || echo GLOB-DENIED) & (type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & (type \"{}\" 1>NUL 2>NUL && echo MARKER-READ-ALLOWED || echo MARKER-READ-DENIED) & (echo tampered > \"{}\" 2>NUL && echo MARKER-WRITE-ALLOWED || echo MARKER-WRITE-DENIED) & type public.txt", + setup_marker.display(), + setup_marker.display() + ), ], cwd: cwd.clone(), expiration: 10_000.into(), @@ -293,6 +298,25 @@ async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow:: stdout.text.contains("public ok"), "allowed reads should still work: {stdout:?}" ); - assert_eq!(stderr.text, ""); + assert!( + stdout.text.contains("MARKER-READ-DENIED"), + "sandboxed command should not read setup readiness: {stdout:?}" + ); + assert!( + stdout.text.contains("MARKER-WRITE-DENIED"), + "sandboxed command should not modify setup readiness: {stdout:?}" + ); + assert!( + !stdout.text.contains("MARKER-READ-ALLOWED"), + "sandboxed command must not read setup readiness: {stdout:?}" + ); + assert!( + !stdout.text.contains("MARKER-WRITE-ALLOWED"), + "sandboxed command must not modify setup readiness: {stdout:?}" + ); + assert!( + sandbox_setup_is_complete(codex_home.path()), + "setup should remain ready after the tamper attempt" + ); Ok(()) } diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index 41fa2f720e..e3376f4055 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -70,6 +70,8 @@ mod sandbox_users; mod setup_runtime_bin; use read_acl_mutex::acquire_read_acl_mutex; use read_acl_mutex::read_acl_mutex_exists; +use sandbox_users::commit_setup_marker; +use sandbox_users::prepare_setup_marker; use sandbox_users::provision_sandbox_users; use sandbox_users::resolve_sandbox_users_group_sid; use sandbox_users::resolve_sid; @@ -475,11 +477,25 @@ fn real_main() -> Result<()> { } fn run_setup(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { + let writes_setup_marker = !payload.refresh_only && payload.mode != SetupMode::ReadAclsOnly; + if writes_setup_marker { + prepare_setup_marker(&payload.codex_home, &payload.real_user)?; + } match payload.mode { SetupMode::ReadAclsOnly => run_read_acl_only(payload, log), SetupMode::ProvisionOnly => run_provision_only(payload, log, sbx_dir), SetupMode::Full => run_setup_full(payload, log, sbx_dir), + }?; + if writes_setup_marker { + commit_setup_marker( + &payload.codex_home, + &payload.offline_username, + &payload.online_username, + &payload.proxy_ports, + payload.allow_local_binding, + )?; } + Ok(()) } fn run_read_acl_only(payload: &Payload, log: &mut dyn Write) -> Result<()> { @@ -554,8 +570,6 @@ fn provision_and_hide_sandbox_users( &payload.codex_home, &payload.offline_username, &payload.online_username, - &payload.proxy_ports, - payload.allow_local_binding, log, ); if let Err(err) = provision_result { diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs index 7f21f185ee..0c7aa21988 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs @@ -10,8 +10,11 @@ use std::ffi::c_void; use std::io::Write; use std::path::Path; use std::path::PathBuf; +use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows_sys::Win32::Foundation::GENERIC_WRITE; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_INFO_1; use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_MEMBERS_INFO_3; @@ -25,12 +28,19 @@ use windows_sys::Win32::NetworkManagement::NetManagement::UF_SCRIPT; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003; use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER; +use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; +use windows_sys::Win32::Security::Authorization::SDDL_REVISION_1; use windows_sys::Win32::Security::CopySid; use windows_sys::Win32::Security::GetLengthSid; use windows_sys::Win32::Security::LookupAccountNameW; use windows_sys::Win32::Security::LookupAccountSidW; +use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; +use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; use windows_sys::Win32::Security::SID_NAME_USE; +use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; +use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; use codex_windows_sandbox::SETUP_VERSION; use codex_windows_sandbox::SetupErrorCode; @@ -61,8 +71,6 @@ pub fn provision_sandbox_users( codex_home: &Path, offline_username: &str, online_username: &str, - proxy_ports: &[u16], - allow_local_binding: bool, log: &mut dyn Write, ) -> Result<()> { ensure_sandbox_users_group(log)?; @@ -80,8 +88,6 @@ pub fn provision_sandbox_users( &offline_password, online_username, &online_password, - proxy_ports, - allow_local_binding, )?; Ok(()) } @@ -402,19 +408,7 @@ fn write_secrets( offline_pwd: &str, online_user: &str, online_pwd: &str, - proxy_ports: &[u16], - allow_local_binding: bool, ) -> Result<()> { - let sandbox_dir = sandbox_dir(codex_home); - std::fs::create_dir_all(&sandbox_dir).map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperUsersFileWriteFailed, - format!( - "failed to create sandbox dir {}: {err}", - sandbox_dir.display() - ), - )) - })?; let secrets_dir = sandbox_secrets_dir(codex_home); std::fs::create_dir_all(&secrets_dir).map_err(|err| { anyhow::Error::new(SetupFailure::new( @@ -448,18 +442,7 @@ fn write_secrets( password: BASE64.encode(online_blob), }, }; - let marker = SetupMarker { - version: SETUP_VERSION, - offline_username: offline_user.to_string(), - online_username: online_user.to_string(), - created_at: chrono::Utc::now().to_rfc3339(), - proxy_ports: proxy_ports.to_vec(), - allow_local_binding, - read_roots: Vec::new(), - write_roots: Vec::new(), - }; let users_path = secrets_dir.join("sandbox_users.json"); - let marker_path = sandbox_dir.join("setup_marker.json"); let users_json = serde_json::to_vec_pretty(&users).map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperUsersFileWriteFailed, @@ -475,6 +458,114 @@ fn write_secrets( ), )) })?; + Ok(()) +} + +// Create the final marker path with its protected ACL before provisioning begins. The empty file +// intentionally fails readiness checks while setup is in progress, and sandbox users cannot read, +// modify, or replace it. Once every setup step succeeds, `commit_setup_marker` writes the valid +// marker contents without changing the file's ACL. +pub(super) fn prepare_setup_marker(codex_home: &Path, real_user: &str) -> Result<()> { + let marker_path = sandbox_dir(codex_home).join("setup_marker.json"); + match std::fs::remove_file(&marker_path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSetupMarkerWriteFailed, + format!( + "remove setup marker file {} failed: {err}", + marker_path.display() + ), + ))); + } + } + + let real_user_sid = resolve_sid(real_user) + .and_then(|sid| string_from_sid_bytes(&sid).map_err(anyhow::Error::msg)) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSetupMarkerWriteFailed, + format!("resolve real user SID for setup marker failed: {err}"), + )) + })?; + let sddl = to_wide(format!( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{real_user_sid})" + )); + let mut security_descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut security_descriptor, + std::ptr::null_mut(), + ) + }; + if converted == 0 { + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSetupMarkerWriteFailed, + format!( + "create setup marker security descriptor failed: {}", + unsafe { GetLastError() } + ), + ))); + } + + let security_attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: security_descriptor, + bInheritHandle: 0, + }; + let marker_path_wide = to_wide(marker_path.as_os_str()); + let marker_handle = unsafe { + CreateFileW( + marker_path_wide.as_ptr(), + GENERIC_WRITE, + /*dwsharemode*/ 0, + &security_attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + /*htemplatefile*/ 0, + ) + }; + let create_error = unsafe { GetLastError() }; + unsafe { + LocalFree(security_descriptor as _); + } + if marker_handle == INVALID_HANDLE_VALUE { + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSetupMarkerWriteFailed, + format!( + "create protected setup marker file {} failed: {}", + marker_path.display(), + create_error + ), + ))); + } + unsafe { + CloseHandle(marker_handle); + } + Ok(()) +} + +pub(super) fn commit_setup_marker( + codex_home: &Path, + offline_user: &str, + online_user: &str, + proxy_ports: &[u16], + allow_local_binding: bool, +) -> Result<()> { + let marker = SetupMarker { + version: SETUP_VERSION, + offline_username: offline_user.to_string(), + online_username: online_user.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + proxy_ports: proxy_ports.to_vec(), + allow_local_binding, + read_roots: Vec::new(), + write_roots: Vec::new(), + }; + let marker_path = sandbox_dir(codex_home).join("setup_marker.json"); let marker_json = serde_json::to_vec_pretty(&marker).map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperSetupMarkerWriteFailed, diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index a0f46fe3f8..c1881b55c8 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -14,6 +14,7 @@ use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths_for_permissions; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; +use crate::identity::sandbox_setup_is_complete; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; @@ -671,6 +672,17 @@ fn report_helper_failure( } } +fn verify_setup_completed(codex_home: &Path) -> Result<()> { + if sandbox_setup_is_complete(codex_home) { + Ok(()) + } else { + Err(failure( + SetupErrorCode::OrchestratorHelperIncomplete, + "setup helper exited successfully before setup completed", + )) + } +} + fn run_setup_exe( payload: &ElevationPayload, needs_elevation: bool, @@ -724,6 +736,7 @@ fn run_setup_exe( status.code(), )); } + verify_setup_completed(codex_home)?; if let Err(err) = clear_setup_error_report(codex_home) { log_note( &format!( @@ -773,6 +786,7 @@ fn run_setup_exe( )); } } + verify_setup_completed(codex_home)?; if let Err(err) = clear_setup_error_report(codex_home) { log_note( &format!("setup orchestrator: failed to clear setup_error.json after success: {err}"), @@ -1067,10 +1081,13 @@ mod tests { use super::offline_proxy_settings_from_env; use super::profile_read_roots; use super::proxy_ports_from_env; + use super::verify_setup_completed; use crate::helper_materialization::BIN_DIRNAME; use crate::helper_materialization::RESOURCES_DIRNAME; use crate::helper_materialization::helper_bin_dir; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; + use crate::setup_error::SetupErrorCode; + use crate::setup_error::extract_failure; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1089,6 +1106,18 @@ mod tests { .collect() } + #[test] + fn setup_completion_requires_ready_artifacts() { + let codex_home = TempDir::new().expect("tempdir"); + let err = verify_setup_completed(codex_home.path()) + .expect_err("missing setup artifacts should fail"); + + assert_eq!( + extract_failure(&err).map(|failure| failure.code), + Some(SetupErrorCode::OrchestratorHelperIncomplete) + ); + } + fn permissions_for( permission_profile: &PermissionProfile, workspace_roots: &[AbsolutePathBuf], diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 4d2a9d57df..0f759ef879 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -31,6 +31,8 @@ pub enum SetupErrorCode { OrchestratorHelperExitNonzero, /// Helper exited non-zero and reading `setup_error.json` failed. OrchestratorHelperReportReadFailed, + /// Helper exited successfully before setup completed. + OrchestratorHelperIncomplete, // Helper (elevated process) failures. /// Helper failed while validating or decoding the request payload. HelperRequestArgsFailed, @@ -48,7 +50,7 @@ pub enum SetupErrorCode { HelperDpapiProtectFailed, /// Helper failed to write the sandbox users secrets file. HelperUsersFileWriteFailed, - /// Helper failed to write the setup marker file. + /// Helper failed to write or protect the setup marker file. HelperSetupMarkerWriteFailed, /// Helper failed to resolve a SID or convert it to a PSID. HelperSidResolveFailed, @@ -83,6 +85,7 @@ impl SetupErrorCode { Self::OrchestratorHelperLaunchCanceled => "orchestrator_helper_launch_canceled", Self::OrchestratorHelperExitNonzero => "orchestrator_helper_exit_nonzero", Self::OrchestratorHelperReportReadFailed => "orchestrator_helper_report_read_failed", + Self::OrchestratorHelperIncomplete => "orchestrator_helper_incomplete", Self::HelperRequestArgsFailed => "helper_request_args_failed", Self::HelperSandboxDirCreateFailed => "helper_sandbox_dir_create_failed", Self::HelperLogFailed => "helper_log_failed", From 4d4837c4951375c877986e239304c95ebbe14633 Mon Sep 17 00:00:00 2001 From: Chris Dong Date: Wed, 3 Jun 2026 16:06:52 -0700 Subject: [PATCH 014/141] log plugin MCP server names (#26002) ## Summary - emit the plugin capability summary's exact MCP server names in `codex_plugin_used` ## Test - `just test -p codex-analytics` - `just test -p codex-core explicit_plugin_mentions_track_plugin_used_analytics` - `just fix -p codex-analytics` --- codex-rs/analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/events.rs | 6 ++++++ codex-rs/core/tests/suite/plugins.rs | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7ceda36a6a..59e3f50467 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -2828,6 +2828,7 @@ fn plugin_used_event_serializes_expected_shape() { "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], "product_client_id": originator().value, + "mcp_server_names": ["mcp-1", "mcp-2"], "thread_id": "thread-3", "turn_id": "turn-3", "model_slug": "gpt-5" diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 61fe75ac27..5367e651e4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -865,6 +865,7 @@ pub(crate) struct CodexPluginMetadata { pub(crate) struct CodexPluginUsedMetadata { #[serde(flatten)] pub(crate) plugin: CodexPluginMetadata, + pub(crate) mcp_server_names: Option>, pub(crate) thread_id: Option, pub(crate) turn_id: Option, pub(crate) model_slug: Option, @@ -971,8 +972,13 @@ pub(crate) fn codex_plugin_used_metadata( tracking: &TrackEventsContext, plugin: PluginTelemetryMetadata, ) -> CodexPluginUsedMetadata { + let mcp_server_names = plugin + .capability_summary + .as_ref() + .map(|summary| summary.mcp_server_names.clone()); CodexPluginUsedMetadata { plugin: codex_plugin_metadata(plugin), + mcp_server_names, thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), model_slug: Some(tracking.model_slug.clone()), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 3390e38ef6..03b1afdfaa 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -388,6 +388,10 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { assert_eq!(event["event_params"]["marketplace_name"], "test"); assert_eq!(event["event_params"]["has_skills"], true); assert_eq!(event["event_params"]["mcp_server_count"], 0); + assert_eq!( + event["event_params"]["mcp_server_names"], + serde_json::json!([]) + ); assert_eq!( event["event_params"]["connector_ids"], serde_json::json!([]) From 8285cd278babadc98458194fb2dbc3ae7f00429c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 3 Jun 2026 20:28:31 -0300 Subject: [PATCH 015/141] fix(tui): add reasoning effort fallback shortcuts (#25623) --- .../chatwidget/tests/popups_and_settings.rs | 101 ++++++++------- codex-rs/tui/src/keymap.rs | 117 +++++++++++++++++- ...__tests__keymap_picker_all_tab_search.snap | 4 +- ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 7 files changed, 174 insertions(+), 58 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ae6ebb091d..bcc960e647 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2426,58 +2426,67 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { assert_chatwidget_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup); } -#[tokio::test] -async fn alt_period_raises_reasoning_effort() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.thread_id = Some(ThreadId::new()); - chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); +async fn assert_reasoning_shortcuts_update_effort( + key_events: [KeyEvent; 2], + expected_effort: ReasoningEffortConfig, + expect_model_update: bool, +) { + for key_event in key_events { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT)); + chat.handle_key_event(key_event); - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events - .iter() - .any(|event| matches!(event, AppEvent::UpdateModel(model) if model == "gpt-5.4")), - "expected model update event; events: {events:?}" - ); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::High)) - )), - "expected reasoning update event; events: {events:?}" - ); - assert!( - events - .iter() - .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })), - "expected no model persistence event; events: {events:?}" - ); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + if expect_model_update { + assert!( + events.iter().any( + |event| matches!(event, AppEvent::UpdateModel(model) if model == "gpt-5.4") + ), + "expected model update event for {key_event:?}; events: {events:?}" + ); + } + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::UpdateReasoningEffort(Some(effort)) if effort == &expected_effort + )), + "expected reasoning update event for {key_event:?}; events: {events:?}" + ); + assert!( + events + .iter() + .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })), + "expected no model persistence event for {key_event:?}; events: {events:?}" + ); + } } #[tokio::test] -async fn alt_comma_lowers_reasoning_effort() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.thread_id = Some(ThreadId::new()); - chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); +async fn reasoning_up_shortcuts_raise_reasoning_effort() { + assert_reasoning_shortcuts_update_effort( + [ + KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), + ], + ReasoningEffortConfig::High, + /*expect_model_update*/ true, + ) + .await; +} - chat.handle_key_event(KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT)); - - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Low)) - )), - "expected reasoning update event; events: {events:?}" - ); - assert!( - events - .iter() - .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })), - "expected no model persistence event; events: {events:?}" - ); +#[tokio::test] +async fn reasoning_down_shortcuts_lower_reasoning_effort() { + assert_reasoning_shortcuts_update_effort( + [ + KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), + ], + ReasoningEffortConfig::Low, + /*expect_model_update*/ false, + ) + .await; } #[tokio::test] diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index ac74632770..b4f00a4d2f 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -25,6 +25,7 @@ use codex_config::types::MAX_FUNCTION_KEY; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; +use serde::Serialize; use std::collections::HashMap; /// Runtime keymap used by TUI input handlers. @@ -423,7 +424,7 @@ impl RuntimeKeymap { )?, }; - let chat = ChatKeymap { + let mut chat = ChatKeymap { interrupt_turn: resolve_bindings( keymap.chat.interrupt_turn.as_ref(), &defaults.chat.interrupt_turn, @@ -738,6 +739,22 @@ impl RuntimeKeymap { cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), }; + // Reasoning arrow aliases are fallback defaults: existing explicit + // bindings on the same input path keep the keys, while explicit + // reasoning bindings remain authoritative. + if keymap.chat.decrease_reasoning_effort.is_none() + && configured_main_surface_alias_is_used(keymap, "shift-down") + { + chat.decrease_reasoning_effort + .retain(|binding| *binding != key_hint::shift(KeyCode::Down)); + } + if keymap.chat.increase_reasoning_effort.is_none() + && configured_main_surface_alias_is_used(keymap, "shift-up") + { + chat.increase_reasoning_effort + .retain(|binding| *binding != key_hint::shift(KeyCode::Up)); + } + let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), @@ -902,8 +919,14 @@ impl RuntimeKeymap { }, chat: ChatKeymap { interrupt_turn: default_bindings![plain(KeyCode::Esc)], - decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], - increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))], + decrease_reasoning_effort: default_bindings![ + alt(KeyCode::Char(',')), + shift(KeyCode::Down) + ], + increase_reasoning_effort: default_bindings![ + alt(KeyCode::Char('.')), + shift(KeyCode::Up) + ], edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], }, composer: ComposerKeymap { @@ -1856,6 +1879,52 @@ fn configured_bindings_to_preserve( configured_bindings } +fn configured_main_surface_alias_is_used(keymap: &TuiKeymap, alias: &str) -> bool { + let mut global = keymap.global.clone(); + if keymap.composer.submit.is_some() { + global.submit = None; + } + if keymap.composer.queue.is_some() { + global.queue = None; + } + if keymap.composer.toggle_shortcuts.is_some() { + global.toggle_shortcuts = None; + } + + // Reasoning shortcuts run before composer/editor key handling, so fallback + // aliases must yield to any explicit binding on the same main-surface input + // path. + configured_context_alias_is_used(&global, alias) + || configured_context_alias_is_used(&keymap.chat, alias) + || configured_context_alias_is_used(&keymap.composer, alias) + || configured_context_alias_is_used(&keymap.editor, alias) + || configured_context_alias_is_used(&keymap.vim_normal, alias) + || configured_context_alias_is_used(&keymap.vim_operator, alias) + || configured_context_alias_is_used(&keymap.vim_text_object, alias) +} + +fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bool { + let Ok(value) = serde_json::to_value(context) else { + return false; + }; + keymap_value_contains_alias(&value, alias) +} + +fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool { + match value { + serde_json::Value::String(value) => value == alias, + serde_json::Value::Array(values) => values + .iter() + .any(|value| keymap_value_contains_alias(value, alias)), + serde_json::Value::Object(values) => values + .values() + .any(|value| keymap_value_contains_alias(value, alias)), + serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => { + false + } + } +} + fn resolve_new_default_bindings( configured: Option<&KeybindingsSpec>, fallback: &[KeyBinding], @@ -2142,11 +2211,17 @@ mod tests { ); assert_eq!( runtime.chat.decrease_reasoning_effort, - vec![key_hint::alt(KeyCode::Char(','))] + vec![ + key_hint::alt(KeyCode::Char(',')), + key_hint::shift(KeyCode::Down), + ] ); assert_eq!( runtime.chat.increase_reasoning_effort, - vec![key_hint::alt(KeyCode::Char('.'))] + vec![ + key_hint::alt(KeyCode::Char('.')), + key_hint::shift(KeyCode::Up), + ] ); assert_eq!( runtime.chat.edit_queued_message, @@ -2220,6 +2295,38 @@ mod tests { ); } + #[test] + fn configured_main_surface_bindings_prune_reasoning_fallback_aliases() { + let mut keymap = TuiKeymap::default(); + keymap.editor.move_up = Some(one("shift-up")); + keymap.vim_text_object.word = Some(one("shift-down")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.editor.move_up, vec![key_hint::shift(KeyCode::Up)]); + assert_eq!( + runtime.vim_text_object.word, + vec![key_hint::shift(KeyCode::Down)] + ); + assert_eq!( + runtime.chat.decrease_reasoning_effort, + vec![key_hint::alt(KeyCode::Char(','))] + ); + assert_eq!( + runtime.chat.increase_reasoning_effort, + vec![key_hint::alt(KeyCode::Char('.'))] + ); + } + + #[test] + fn explicit_reasoning_binding_still_conflicts_with_editor_binding() { + let mut keymap = TuiKeymap::default(); + keymap.editor.move_up = Some(one("shift-up")); + keymap.chat.increase_reasoning_effort = Some(one("shift-up")); + + expect_conflict(&keymap, "chat.increase_reasoning_effort", "editor.move_up"); + } + #[test] fn configured_legacy_list_bindings_prune_new_default_overlaps() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 06efc646a8..454f2adb6a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -9,8 +9,8 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default -Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default -Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default +Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default +Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 7969d461ef..5e6c520f36 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -17,6 +17,6 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Chat Interrupt Turn esc - Chat Decrease Reasoning Effort alt-, + Chat Decrease Reasoning Effort alt-,, shift-down left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 4bb6c4dbfc..7a1e796cd7 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -20,8 +20,8 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default -Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default -Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default +Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default +Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index a4a65e3321..a94c326890 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -18,6 +18,6 @@ expression: "render_picker(params, 78)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Chat Interrupt Turn esc - Chat Decrease Reasoning Effort alt-, + Chat Decrease Reasoning Effort alt-,, shift-down left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 1138ebbf1e..5d0ae55ef1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -17,6 +17,6 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r Chat Interrupt Turn esc - Chat Decrease Reasoning Effort alt-, + Chat Decrease Reasoning Effort alt-,, shift-down left/right group · enter edit shortcut · * custom · - unbound · esc close From 80b65e99457344f5f05fb4948eb35c6b1d320e3f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 3 Jun 2026 20:30:15 -0300 Subject: [PATCH 016/141] feat(tui): add /app desktop handoff (#25638) --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/history_ui.rs | 108 +++++++++++++++++- codex-rs/tui/src/app/history_ui_tests.rs | 29 +++++ ...ts__desktop_thread_open_error_history.snap | 5 + ..._tests__desktop_thread_opened_history.snap | 5 + codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/bottom_pane/command_popup.rs | 19 +++ ...mmand_popup__tests__command_popup_app.snap | 20 ++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 11 ++ ...ithout_thread_id_shows_starting_error.snap | 5 + .../src/chatwidget/tests/slash_commands.rs | 30 +++++ codex-rs/tui/src/slash_command.rs | 5 + 12 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/app/history_ui_tests.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3570491d1a..b4dfb93fb7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -403,6 +403,9 @@ impl App { AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); } + AppEvent::OpenDesktopThread { thread_id } => { + self.open_desktop_thread(thread_id); + } AppEvent::PetSelected { pet_id } => { self.handle_pet_selected(tui, pet_id); } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index e923db1770..4cb5072daf 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -1,10 +1,12 @@ -//! Terminal history and clear-screen UI helpers for the TUI app. +//! Terminal history, desktop handoff, and clear-screen UI helpers for the TUI app. //! //! This module owns rendering the fresh session header, clearing inline or alternate-screen UI //! state, and resetting transcript-related app state after `/clear` or Ctrl-L. use super::*; +const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in Codex Desktop."; + impl App { pub(super) fn open_url_in_browser(&mut self, url: String) { if let Err(err) = webbrowser::open(&url) { @@ -17,6 +19,20 @@ impl App { .add_info_message(format!("Opened {url} in your browser."), /*hint*/ None); } + pub(super) fn open_desktop_thread(&mut self, thread_id: ThreadId) { + let url = format!("codex://threads/{thread_id}"); + if let Err(err) = open_desktop_thread_url(&url) { + self.chat_widget + .add_error_message(desktop_thread_open_error_message(&err)); + return; + } + + self.chat_widget.add_info_message( + DESKTOP_THREAD_OPENED_MESSAGE.to_string(), + /*hint*/ None, + ); + } + pub(super) fn clear_ui_header_lines_with_version( &self, width: u16, @@ -99,3 +115,93 @@ impl App { self.backtrack_render_pending = false; } } + +fn desktop_thread_open_error_message(err: &str) -> String { + format!( + "Failed to open this session in Codex Desktop: {err}. Install or launch Codex Desktop and try again." + ) +} + +#[cfg(target_os = "macos")] +fn open_desktop_thread_url(url: &str) -> Result<(), String> { + let status = std::process::Command::new("open") + .arg(url) + .status() + .map_err(|err| format!("failed to invoke `open`: {err}"))?; + + if status.success() { + Ok(()) + } else { + Err(format!("`open {url}` exited with {status}")) + } +} + +#[cfg(target_os = "windows")] +fn open_desktop_thread_url(url: &str) -> Result<(), String> { + let script = windows_desktop_app_launch_script(url); + let output = std::process::Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-Command") + .arg(&script) + .output() + .map_err(|err| format!("failed to launch Codex Desktop through PowerShell: {err}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!( + "failed to launch Codex Desktop through PowerShell with {}", + output.status + )) + } else { + Err(stderr) + } +} + +#[cfg(target_os = "windows")] +fn windows_desktop_app_launch_script(url: &str) -> String { + let url = powershell_single_quoted_string(url); + format!( + r#" +$ErrorActionPreference = 'Stop' +$url = {url} + +$installLocation = (Get-AppxPackage -Name OpenAI.Codex -ErrorAction SilentlyContinue).InstallLocation +if ([string]::IsNullOrWhiteSpace($installLocation)) {{ + Write-Error 'Codex Desktop package is not installed' + exit 1 +}} + +$appDir = Join-Path $installLocation 'app' +$exe = Join-Path $appDir 'Codex.exe' +$app = Join-Path $appDir 'resources\app.asar' +if (-not (Test-Path $exe)) {{ + Write-Error "Codex Desktop executable not found at $exe" + exit 1 +}} +if (-not (Test-Path $app)) {{ + Write-Error "Codex Desktop app bundle not found at $app" + exit 1 +}} + +Start-Process -FilePath $exe -WorkingDirectory $appDir -ArgumentList @('resources\app.asar', $url) +"# + ) +} + +#[cfg(target_os = "windows")] +fn powershell_single_quoted_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn open_desktop_thread_url(_url: &str) -> Result<(), String> { + Err("Codex Desktop is only available on macOS and Windows".to_string()) +} + +#[cfg(test)] +#[path = "history_ui_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/history_ui_tests.rs b/codex-rs/tui/src/app/history_ui_tests.rs new file mode 100644 index 0000000000..750c665355 --- /dev/null +++ b/codex-rs/tui/src/app/history_ui_tests.rs @@ -0,0 +1,29 @@ +use super::*; +use crate::history_cell; +use crate::history_cell::HistoryCell; + +#[test] +fn desktop_thread_opened_history_snapshot() { + let cell = history_cell::new_info_event( + DESKTOP_THREAD_OPENED_MESSAGE.to_string(), + /*hint*/ None, + ); + + insta::assert_snapshot!("desktop_thread_opened_history", render_cell(&cell)); +} + +#[test] +fn desktop_thread_open_error_history_snapshot() { + let cell = history_cell::new_error_event(desktop_thread_open_error_message("launch failed")); + + insta::assert_snapshot!("desktop_thread_open_error_history", render_cell(&cell)); +} + +fn render_cell(cell: &impl HistoryCell) -> String { + let lines = cell.display_lines(/*width*/ 80); + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap new file mode 100644 index 0000000000..256dd96935 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/history_ui_tests.rs +expression: render_cell(&cell) +--- +■ Failed to open this session in Codex Desktop: launch failed. Install or launch Codex Desktop and try again. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap new file mode 100644 index 0000000000..a1f5264511 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/history_ui_tests.rs +expression: render_cell(&cell) +--- +• Opened this session in Codex Desktop. diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 228ee0c417..c2f9ee3f31 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -330,6 +330,11 @@ pub(crate) enum AppEvent { url: String, }, + /// Open the current thread in Codex Desktop. + OpenDesktopThread { + thread_id: ThreadId, + }, + /// Persist a pet selection and reload the ambient pet. PetSelected { pet_id: String, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 9d38d4033c..9e0e1b6494 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -393,6 +393,25 @@ mod tests { ); } + #[cfg(any(target_os = "macos", target_os = "windows"))] + #[test] + fn app_command_popup_snapshot() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/app".to_string()); + + let width = 72; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + popup.calculate_required_height(width), + ); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + + insta::assert_snapshot!("command_popup_app", format!("{buf:?}")); + } + #[test] fn prefix_filter_limits_matches_for_ac() { let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap new file mode 100644 index 0000000000..95e28a39c5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/command_popup.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 72, height: 2 }, + content: [ + " /app continue this session in Codex Desktop ", + " /approve approve one retry of a recent auto-review denial ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 50, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 6, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 60, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 99833da8a8..08c6db56dc 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -202,6 +202,16 @@ impl ChatWidget { SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); } + SlashCommand::App => { + let Some(thread_id) = self.thread_id else { + self.add_error_message( + "Session is still starting; try /app again in a moment.".to_string(), + ); + return; + }; + self.app_event_tx + .send(AppEvent::OpenDesktopThread { thread_id }); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME); if init_target.exists() { @@ -974,6 +984,7 @@ impl ChatWidget { | SlashCommand::Raw | SlashCommand::Vim | SlashCommand::Diff + | SlashCommand::App | SlashCommand::Rename | SlashCommand::TestApproval => QueueDrain::Continue, SlashCommand::Feedback diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap new file mode 100644 index 0000000000..f7ff40ea5a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "lines_to_single_string(&cells[0])" +--- +■ Session is still starting; try /app again in a moment. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 4f425c25c9..099ae1d76d 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2052,6 +2052,36 @@ async fn slash_fork_requests_current_fork() { assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } +#[tokio::test] +async fn slash_app_requests_desktop_handoff() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + + chat.dispatch_command(SlashCommand::App); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::OpenDesktopThread { + thread_id: actual_thread_id, + }) if actual_thread_id == thread_id + ); +} + +#[tokio::test] +async fn slash_app_without_thread_id_shows_starting_error() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::App); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected app startup error"); + assert_chatwidget_snapshot!( + "slash_app_without_thread_id_shows_starting_error", + lines_to_single_string(&cells[0]) + ); +} + #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 3fccdf5967..805f266e0a 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,6 +33,7 @@ pub enum SlashCommand { Archive, Resume, Fork, + App, Init, Compact, Plan, @@ -90,6 +91,7 @@ impl SlashCommand { SlashCommand::Archive => "archive this session and exit", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", + SlashCommand::App => "continue this session in Codex Desktop", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Copy => "copy last response as markdown", SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", @@ -213,6 +215,7 @@ impl SlashCommand { | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop + | SlashCommand::App | SlashCommand::Goal | SlashCommand::Mcp | SlashCommand::Apps @@ -239,6 +242,7 @@ impl SlashCommand { match self { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), SlashCommand::Copy => !cfg!(target_os = "android"), + SlashCommand::App => cfg!(any(target_os = "macos", target_os = "windows")), SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), _ => true, } @@ -285,6 +289,7 @@ mod tests { assert!(SlashCommand::Raw.available_during_task()); assert!(SlashCommand::Raw.available_in_side_conversation()); assert!(SlashCommand::Raw.supports_inline_args()); + assert!(SlashCommand::App.available_during_task()); } #[test] From 6bcccb0ee6b875a41ed9e8fb7e299a6b98dd3faa Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 3 Jun 2026 19:08:19 -0700 Subject: [PATCH 017/141] cli: add package path from install context (#26189) ## Why Codex package installs include helper binaries in `codex-path`, such as the bundled `rg`. Package-layout launches should add that directory before user commands run, but standalone launches were missing it while npm launches only worked because `codex.js` had its own legacy `PATH` rewrite. That made npm and standalone package behavior diverge. Shell snapshot restoration can also reset `PATH` after runtime setup. Any package-owned `PATH` prepend has to be recorded as an explicit runtime override so shells, unified exec, and user-shell commands keep access to `codex-path` after a snapshot is sourced. ## Repro Before this change, a curl-installed package could contain `rg` under `codex-path` but still fail to put it on `PATH`: ```shell mkdir /tmp/test-codex-curl curl -fsSL https://chatgpt.com/codex/install.sh \ | CODEX_HOME=/tmp/test-codex-curl CODEX_NON_INTERACTIVE=1 sh /tmp/test-codex-curl/packages/standalone/current/bin/codex exec \ --skip-git-repo-check 'print `which -a rg`' find /tmp/test-codex-curl -name rg ``` The `which -a rg` output omitted the packaged helper even though `find` showed it under `/tmp/test-codex-curl/packages/standalone/releases/.../codex-path/rg`. The npm install path behaved differently only because `codex-cli/bin/codex.js` had legacy `PATH` rewriting: ```shell mkdir /tmp/test-codex-npm cd /tmp/test-codex-npm npm install @openai/codex ./node_modules/.bin/codex exec --skip-git-repo-check 'print `which -a rg`' ``` That printed the npm package's `vendor//codex-path/rg` first. This PR moves that behavior into Rust-side package launch setup so curl/standalone and npm/bun launches agree without JS rewriting `PATH`. ## What Changed - `codex-rs/arg0` now uses `InstallContext::current().package_layout.path_dir` to prepend the package helper directory before any threads are created. - Package helper `PATH` setup is independent from the temporary arg0 alias setup, so `codex-path` is still added even if CODEX_HOME tempdir, lock, or symlink setup fails. - `codex-rs/install-context` detects the canonical package layout we ship: `bin/`, `codex-resources/`, and `codex-path/` next to `codex-package.json`. - Shell, local unified exec, and user-shell runtimes now record package `codex-path` prepends in `explicit_env_overrides`, matching the existing zsh-fork behavior so shell snapshots cannot restore over the package helper path. - Remote unified exec requests do not receive the local app-server package path overlay. - `codex-cli/bin/codex.js` no longer computes or overrides `PATH`; it only locates the native binary in the canonical package layout and passes npm/bun management metadata. - Added regression tests for `PATH` ordering, package layout detection, and shell snapshot preservation of package path prepends. ## Verification - `node --check codex-cli/bin/codex.js` - `just test -p codex-install-context -p codex-arg0` - `just test -p codex-core user_shell_snapshot_preserves_package_path_prepend` - `just test -p codex-core tools::runtimes::tests` - `just bazel-lock-update` - `just bazel-lock-check` - `just fix -p codex-install-context -p codex-arg0 -p codex-core` --- codex-cli/bin/codex.js | 76 ++----- codex-rs/Cargo.lock | 2 + codex-rs/arg0/Cargo.toml | 4 + codex-rs/arg0/src/lib.rs | 209 +++++++++++++++--- codex-rs/core/src/tasks/user_shell.rs | 76 ++++++- codex-rs/core/src/tasks/user_shell_tests.rs | 71 ++++++ codex-rs/core/src/tools/runtimes/mod.rs | 111 ++++++++-- codex-rs/core/src/tools/runtimes/mod_tests.rs | 207 ++++++++++++++++- codex-rs/core/src/tools/runtimes/shell.rs | 16 +- .../core/src/tools/runtimes/unified_exec.rs | 20 +- codex-rs/install-context/src/lib.rs | 9 +- 11 files changed, 674 insertions(+), 127 deletions(-) create mode 100644 codex-rs/core/src/tasks/user_shell_tests.rs diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 1a43ce7e1f..5cc5194183 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -75,45 +75,25 @@ if (!platformPackage) { throw new Error(`Unsupported target triple: ${targetTriple}`); } -const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex"; -const localVendorRoot = path.join(__dirname, "..", "vendor"); -const packageBinaryPath = (vendorRoot) => - path.join(vendorRoot, targetTriple, "bin", codexBinaryName); -const legacyBinaryPath = (vendorRoot) => - path.join(vendorRoot, targetTriple, "codex", codexBinaryName); - -function resolveNativePackage(vendorRoot) { - const packageRoot = path.join(vendorRoot, targetTriple); - const binaryPath = packageBinaryPath(vendorRoot); - if (existsSync(binaryPath)) { - return { - binaryPath, - pathDir: path.join(packageRoot, "codex-path"), - }; +function findCodexExecutable() { + let vendorRoot; + try { + const packageJsonPath = require.resolve(`${platformPackage}/package.json`); + vendorRoot = path.join(path.dirname(packageJsonPath), "vendor"); + } catch { + vendorRoot = path.join(__dirname, "..", "vendor"); } - const legacyPath = legacyBinaryPath(vendorRoot); - if (existsSync(legacyPath)) { - return { - binaryPath: legacyPath, - pathDir: path.join(packageRoot, "path"), - }; - } - - return null; -} - -let nativePackage; -try { - const packageJsonPath = require.resolve(`${platformPackage}/package.json`); - nativePackage = resolveNativePackage( - path.join(path.dirname(packageJsonPath), "vendor"), + const codexExecutable = path.join( + vendorRoot, + targetTriple, + "bin", + process.platform === "win32" ? "codex.exe" : "codex", ); -} catch { - nativePackage = resolveNativePackage(localVendorRoot); -} + if (existsSync(codexExecutable)) { + return codexExecutable; + } -if (!nativePackage) { const packageManager = detectPackageManager(); const updateCommand = packageManager === "bun" @@ -124,7 +104,7 @@ if (!nativePackage) { ); } -const { binaryPath, pathDir } = nativePackage; +const binaryPath = findCodexExecutable(); // Use an asynchronous spawn instead of spawnSync so that Node is able to // respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is @@ -132,16 +112,6 @@ const { binaryPath, pathDir } = nativePackage; // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. -function getUpdatedPath(newDirs) { - const pathSep = process.platform === "win32" ? ";" : ":"; - const existingPath = process.env.PATH || ""; - const updatedPath = [ - ...newDirs, - ...existingPath.split(pathSep).filter(Boolean), - ].join(pathSep); - return updatedPath; -} - /** * Use heuristics to detect the package manager that was used to install Codex * in order to give the user a hint about how to update it. @@ -167,19 +137,15 @@ function detectPackageManager() { return userAgent ? "npm" : null; } -const additionalDirs = []; -if (existsSync(pathDir)) { - additionalDirs.push(pathDir); -} -const updatedPath = getUpdatedPath(additionalDirs); - -const env = { ...process.env, PATH: updatedPath }; const packageManagerEnvVar = detectPackageManager() === "bun" ? "CODEX_MANAGED_BY_BUN" : "CODEX_MANAGED_BY_NPM"; -env[packageManagerEnvVar] = "1"; -env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, "..")); +const env = { + ...process.env, + [packageManagerEnvVar]: "1", + CODEX_MANAGED_PACKAGE_ROOT: realpathSync(path.join(__dirname, "..")), +}; const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2183d91b64..e9c171d778 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2145,12 +2145,14 @@ dependencies = [ "anyhow", "codex-apply-patch", "codex-exec-server", + "codex-install-context", "codex-linux-sandbox", "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", "codex-utils-home-dir", "dotenvy", + "pretty_assertions", "tempfile", "tokio", ] diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e..55526b4d06 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -16,6 +16,7 @@ workspace = true anyhow = { workspace = true } codex-apply-patch = { workspace = true } codex-exec-server = { workspace = true } +codex-install-context = { workspace = true } codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } @@ -24,3 +25,6 @@ codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 87940f1182..ba254d57ab 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fs::File; use std::future::Future; use std::path::Path; @@ -5,6 +6,7 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; +use codex_install_context::InstallContext; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] @@ -138,13 +140,36 @@ pub fn arg0_dispatch() -> Option { // before creating any threads/the Tokio runtime. load_dotenv(); - match prepend_path_entry_for_codex_aliases() { - Ok(path_entry) => Some(path_entry), + let (path_entry_guard, updated_path_env_var) = prepare_path_env_var_with_aliases( + InstallContext::current(), + std::env::var_os("PATH"), + prepare_path_entry_for_codex_aliases, + ); + if let Some(updated_path_env_var) = updated_path_env_var { + // It is safe to call set_var() because our process is single-threaded at + // this point in its execution. + unsafe { + std::env::set_var("PATH", updated_path_env_var); + } + } + path_entry_guard +} + +fn prepare_path_env_var_with_aliases( + install_context: &InstallContext, + existing_path: Option, + prepare_aliases: impl FnOnce(Option) -> std::io::Result<(Arg0PathEntryGuard, OsString)>, +) -> (Option, Option) { + let package_path = path_env_with_package_path_dir(install_context, existing_path.clone()); + let path_for_aliases = package_path.clone().or(existing_path); + + match prepare_aliases(path_for_aliases) { + Ok((path_entry, updated_path_env_var)) => (Some(path_entry), Some(updated_path_env_var)), Err(err) => { // It is possible that Codex will proceed successfully even if - // updating the PATH fails, so warn the user and move on. - eprintln!("WARNING: proceeding, even though we could not update PATH: {err}"); - None + // creating helper aliases fails, so warn the user and move on. + eprintln!("WARNING: proceeding, even though we could not create PATH aliases: {err}"); + (None, package_path) } } } @@ -285,15 +310,16 @@ where /// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable /// with the hidden `--codex-run-as-apply-patch` flag. /// -/// This temporary directory is prepended to the PATH environment variable so -/// that `apply_patch` can be on the PATH without requiring the user to -/// install a separate `apply_patch` executable, simplifying the deployment of -/// Codex CLI. +/// Returns the temporary directory guard and the PATH value that prepends the +/// temporary directory so `apply_patch` can be on the PATH without requiring the +/// user to install a separate executable, simplifying the deployment of Codex +/// CLI. /// Note: In debug builds the temp-dir guard is disabled to ease local testing. /// -/// IMPORTANT: This function modifies the PATH environment variable, so it MUST -/// be called before multiple threads are spawned. -pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { +/// IMPORTANT: Callers must update PATH before multiple threads are spawned. +fn prepare_path_entry_for_codex_aliases( + existing_path: Option, +) -> std::io::Result<(Arg0PathEntryGuard, OsString)> { let codex_home = find_codex_home()?; #[cfg(not(debug_assertions))] { @@ -371,27 +397,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - let mut path_env_var = - std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len()); - path_env_var.push(path); - path_env_var.push(PATH_SEPARATOR); - path_env_var.push(existing_path); - path_env_var - } - None => path.as_os_str().to_owned(), - }; - - unsafe { - std::env::set_var("PATH", updated_path_env_var); - } + let updated_path_env_var = path_env_with_entry(path, existing_path); let paths = Arg0DispatchPaths { codex_self_exe: std::env::current_exe().ok(), @@ -417,7 +423,41 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result, +) -> Option { + let path_dir = install_context + .package_layout + .as_ref() + .and_then(|package_layout| package_layout.path_dir.as_ref())?; + Some(path_env_with_entry(path_dir.as_path(), existing_path)) +} + +fn path_env_with_entry(path_entry: &Path, existing_path: Option) -> OsString { + #[cfg(unix)] + const PATH_SEPARATOR: &str = ":"; + + #[cfg(windows)] + const PATH_SEPARATOR: &str = ";"; + + let capacity = path_entry.as_os_str().len() + + existing_path + .as_ref() + .map_or(0, |existing_path| 1 + existing_path.len()); + let mut path_env_var = OsString::with_capacity(capacity); + path_env_var.push(path_entry); + if let Some(existing_path) = existing_path { + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(existing_path); + } + path_env_var } fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> { @@ -475,12 +515,25 @@ mod tests { use super::run_main_with_arg0_guard; #[cfg(unix)] use anyhow::ensure; + use codex_install_context::CodexPackageLayout; + use codex_install_context::InstallContext; + use codex_install_context::InstallMethod; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; use std::fs; use std::fs::File; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; + struct PackagePathTestFixture { + _temp_dir: TempDir, + arg0_dir: PathBuf, + existing_dir: PathBuf, + install_context: InstallContext, + path_dir: AbsolutePathBuf, + } + fn create_lock(dir: &Path) -> std::io::Result { let lock_path = dir.join(LOCK_FILENAME); File::options() @@ -491,6 +544,37 @@ mod tests { .open(lock_path) } + fn package_path_test_fixture() -> anyhow::Result { + let temp_dir = TempDir::new()?; + let arg0_dir = temp_dir.path().join("arg0"); + let package_dir = temp_dir.path().join("package"); + let bin_dir = package_dir.join("bin"); + let path_dir = package_dir.join("codex-path"); + let existing_dir = temp_dir.path().join("existing-bin"); + fs::create_dir_all(&arg0_dir)?; + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&path_dir)?; + fs::create_dir_all(&existing_dir)?; + let path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + let install_context = InstallContext { + method: InstallMethod::Other, + package_layout: Some(CodexPackageLayout { + package_dir: AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?, + bin_dir: AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?, + resources_dir: None, + path_dir: Some(path_dir.clone()), + }), + }; + + Ok(PackagePathTestFixture { + _temp_dir: temp_dir, + arg0_dir, + existing_dir, + install_context, + path_dir, + }) + } + #[test] fn linux_sandbox_exe_path_prefers_codex_linux_sandbox_alias() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -513,6 +597,63 @@ mod tests { Ok(()) } + #[test] + fn path_env_can_prepend_package_path_before_arg0_alias_dir() -> anyhow::Result<()> { + let fixture = package_path_test_fixture()?; + + let package_path = super::path_env_with_package_path_dir( + &fixture.install_context, + Some(fixture.existing_dir.as_os_str().to_owned()), + ) + .expect("package path dir should update PATH"); + let updated_path = super::path_env_with_entry(&fixture.arg0_dir, Some(package_path)); + + assert_eq!( + std::env::split_paths(&updated_path).collect::>(), + vec![ + fixture.arg0_dir, + fixture.path_dir.as_path().to_path_buf(), + fixture.existing_dir + ], + ); + Ok(()) + } + + #[test] + fn package_path_survives_arg0_alias_setup_failure() -> anyhow::Result<()> { + let fixture = package_path_test_fixture()?; + + let (path_entry_guard, updated_path_env_var) = super::prepare_path_env_var_with_aliases( + &fixture.install_context, + Some(fixture.existing_dir.as_os_str().to_owned()), + |path_for_aliases| { + assert_eq!( + std::env::split_paths( + &path_for_aliases.expect("package PATH should be passed to alias setup") + ) + .collect::>(), + vec![ + fixture.path_dir.as_path().to_path_buf(), + fixture.existing_dir.clone() + ], + ); + Err(std::io::Error::other("alias setup failed")) + }, + ); + + assert!(path_entry_guard.is_none()); + let updated_path_env_var = + updated_path_env_var.expect("package PATH should survive alias setup failure"); + assert_eq!( + std::env::split_paths(&updated_path_env_var).collect::>(), + vec![ + fixture.path_dir.as_path().to_path_buf(), + fixture.existing_dir + ], + ); + Ok(()) + } + #[cfg(unix)] #[test] fn run_main_with_arg0_guard_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index e016a725c4..6affc3d039 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -1,9 +1,11 @@ +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; +use codex_utils_absolute_path::AbsolutePathBuf; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; @@ -15,8 +17,12 @@ use crate::exec_env::create_env; use crate::sandboxing::ExecRequest; use crate::session::TurnInput; use crate::session::turn_context::TurnContext; +use crate::shell::Shell; use crate::state::TaskKind; use crate::tools::format_exec_output_str; +use crate::tools::runtimes::RuntimePathPrepends; +#[cfg(unix)] +use crate::tools::runtimes::apply_package_path_prepend; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::strip_managed_proxy_env; use crate::turn_timing::now_unix_timestamp_ms; @@ -131,13 +137,13 @@ pub(crate) async fn execute_user_shell_command( if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { strip_managed_proxy_env(&mut exec_env_map); } - let exec_command = maybe_wrap_shell_lc_with_snapshot( + let exec_command = prepare_user_shell_exec_command( &display_command, session_shell.as_ref(), #[allow(deprecated)] &turn_context.cwd, &turn_context.shell_environment_policy.r#set, - &exec_env_map, + &mut exec_env_map, ); let call_id = Uuid::new_v4().to_string(); @@ -328,6 +334,68 @@ pub(crate) async fn execute_user_shell_command( } } +fn prepare_user_shell_exec_command( + display_command: &[String], + session_shell: &Shell, + cwd: &AbsolutePathBuf, + shell_environment_set: &HashMap, + exec_env_map: &mut HashMap, +) -> Vec { + #[cfg(unix)] + { + prepare_user_shell_exec_command_with_path_prepend( + display_command, + session_shell, + cwd, + shell_environment_set, + exec_env_map, + apply_package_path_prepend, + ) + } + + #[cfg(not(unix))] + { + maybe_wrap_shell_lc_with_snapshot( + display_command, + session_shell, + cwd, + shell_environment_set, + exec_env_map, + // On non-Unix targets, arg0 has already prepended the package path + // to the process PATH before create_env() builds exec_env_map. + // RuntimePathPrepends is only needed for Unix shell snapshot replay. + &RuntimePathPrepends::default(), + ) + } +} + +/// Prepares a user-shell command after adding runtime-owned PATH entries. +/// +/// The callback mutates the live exec environment for commands that are not +/// wrapped with a shell snapshot and records only the runtime-owned entries so +/// snapshot wrapping can reapply them after restoring the user's snapshot PATH. +#[cfg(unix)] +fn prepare_user_shell_exec_command_with_path_prepend( + display_command: &[String], + session_shell: &Shell, + cwd: &AbsolutePathBuf, + shell_environment_set: &HashMap, + exec_env_map: &mut HashMap, + prepend_runtime_path: impl FnOnce(&mut HashMap, &mut RuntimePathPrepends), +) -> Vec { + let explicit_env_overrides = shell_environment_set.clone(); + let mut runtime_path_prepends = RuntimePathPrepends::default(); + prepend_runtime_path(exec_env_map, &mut runtime_path_prepends); + maybe_wrap_shell_lc_with_snapshot( + display_command, + session_shell, + cwd, + &explicit_env_overrides, + exec_env_map, + &runtime_path_prepends, + ) +} + async fn persist_user_shell_output( session: &Session, turn_context: &TurnContext, @@ -351,3 +419,7 @@ async fn persist_user_shell_output( .inject_no_new_turn(vec![output_item], Some(turn_context)) .await; } + +#[cfg(all(test, unix))] +#[path = "user_shell_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tasks/user_shell_tests.rs b/codex-rs/core/src/tasks/user_shell_tests.rs new file mode 100644 index 0000000000..442c5cc614 --- /dev/null +++ b/codex-rs/core/src/tasks/user_shell_tests.rs @@ -0,0 +1,71 @@ +use super::*; +use crate::shell::Shell; +use crate::shell::ShellType; +use crate::shell_snapshot::ShellSnapshot; +use core_test_support::PathExt; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +use std::process::Command; +use tokio::sync::watch; + +fn shell_with_snapshot( + shell_type: ShellType, + shell_path: &str, + snapshot_path: AbsolutePathBuf, + snapshot_cwd: AbsolutePathBuf, +) -> Shell { + let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { + path: snapshot_path, + cwd: snapshot_cwd, + }))); + Shell { + shell_type, + shell_path: PathBuf::from(shell_path), + shell_snapshot, + } +} + +#[test] +fn user_shell_snapshot_preserves_package_path_prepend() { + let dir = tempfile::tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let package_path_dir = dir.path().join("codex-path"); + let mut env = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let rewritten = prepare_user_shell_exec_command_with_path_prepend( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &mut env, + |env, runtime_path_prepends| { + runtime_path_prepends.prepend(env, package_path_dir.as_path()); + }, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", env.get("PATH").expect("PATH should be set")) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("{}:/snapshot/bin", package_path_dir.display()) + ); +} diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index b237f44c40..29d323b635 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -10,6 +10,8 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::sandboxing::ToolError; +#[cfg(unix)] +use codex_install_context::InstallContext; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; use codex_network_proxy::CUSTOM_CA_ENV_KEYS; @@ -86,17 +88,87 @@ pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { } } +/// Prepends `path_entry` to `PATH`, removing duplicate and empty existing +/// entries. +/// +/// Returns the updated `PATH` value when `env` was changed. Returns `None` when +/// `path_entry` is empty, leaving `env` untouched so an empty entry does not add +/// the current working directory to command lookup. #[cfg(unix)] -fn prepend_path_entry(env: &mut HashMap, path_entry: &str) -> String { - let updated_path = match env.get("PATH") { - Some(path) if !path.is_empty() => std::iter::once(path_entry) - .chain(path.split(':').filter(|entry| *entry != path_entry)) +fn prepend_path_entry(env: &mut HashMap, path_entry: &str) -> Option { + if path_entry.is_empty() { + None + } else { + let updated_path = match env.get("PATH") { + Some(path) if !path.is_empty() => std::iter::once(path_entry) + .chain( + path.split(':') + .filter(|entry| !entry.is_empty() && *entry != path_entry), + ) + .collect::>() + .join(":"), + _ => path_entry.to_string(), + }; + env.insert("PATH".to_string(), updated_path.clone()); + Some(updated_path) + } +} + +/// PATH entries owned by Codex runtime setup. +/// +/// These are applied to the live exec environment immediately and replayed after +/// restoring a shell snapshot, unless the user explicitly overrides `PATH`. +#[derive(Debug, Default, Eq, PartialEq)] +pub(crate) struct RuntimePathPrepends { + entries: Vec, +} + +impl RuntimePathPrepends { + #[cfg(unix)] + pub(crate) fn prepend(&mut self, env: &mut HashMap, path_entry: &Path) { + let path_entry = path_entry.to_string_lossy().to_string(); + if prepend_path_entry(env, &path_entry).is_some() { + self.entries.retain(|entry| entry != &path_entry); + self.entries.push(path_entry); + } + } + + fn shell_exports_after_snapshot( + &self, + explicit_env_overrides: &HashMap, + ) -> String { + if explicit_env_overrides.contains_key("PATH") { + return String::new(); + } + + self.entries + .iter() + .filter(|entry| !entry.is_empty()) + .map(|entry| { + let entry = shell_single_quote(entry); + format!( + "if [ -n \"${{PATH:-}}\" ]; then export PATH='{entry}':\"$PATH\"; else export PATH='{entry}'; fi" + ) + }) .collect::>() - .join(":"), - _ => path_entry.to_string(), + .join("\n") + } +} + +#[cfg(unix)] +pub(crate) fn apply_package_path_prepend( + env: &mut HashMap, + runtime_path_prepends: &mut RuntimePathPrepends, +) { + let Some(path_dir) = InstallContext::current() + .package_layout + .as_ref() + .and_then(|package_layout| package_layout.path_dir.as_ref()) + else { + return; }; - env.insert("PATH".to_string(), updated_path.clone()); - updated_path + + runtime_path_prepends.prepend(env, path_dir.as_path()); } #[cfg(unix)] @@ -107,21 +179,19 @@ pub(crate) fn prepend_zsh_fork_bin_to_path( let zsh_bin_dir = shell_zsh_path .parent() .map(|path| path.to_string_lossy().to_string())?; - Some(prepend_path_entry(env, &zsh_bin_dir)) + prepend_path_entry(env, &zsh_bin_dir) } #[cfg(unix)] pub(crate) fn apply_zsh_fork_path_prepend( env: &mut HashMap, - explicit_env_overrides: &mut HashMap, + runtime_path_prepends: &mut RuntimePathPrepends, shell_zsh_path: &Path, ) { - let Some(updated_path) = prepend_zsh_fork_bin_to_path(env, shell_zsh_path) else { + let Some(zsh_bin_dir) = shell_zsh_path.parent() else { return; }; - // Snapshot wrapping restores explicit overrides after sourcing the shell - // snapshot, so capture this PATH override there as well. - explicit_env_overrides.insert("PATH".to_string(), updated_path); + runtime_path_prepends.prepend(env, zsh_bin_dir); } pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( @@ -172,12 +242,17 @@ pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( /// environment. We need access to both so snapshot restore logic can preserve /// runtime-only vars like `CODEX_THREAD_ID` without pretending they came from /// the explicit override policy. +/// +/// `runtime_path_prepends` contains Codex-owned PATH entries already applied to +/// the live `env`; snapshot wrapping replays them after restoring the snapshot +/// PATH unless the user explicitly overrides `PATH`. pub(crate) fn maybe_wrap_shell_lc_with_snapshot( command: &[String], session_shell: &Shell, cwd: &AbsolutePathBuf, explicit_env_overrides: &HashMap, env: &HashMap, + runtime_path_prepends: &RuntimePathPrepends, ) -> Vec { if cfg!(windows) { return command.to_vec(); @@ -219,8 +294,14 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( } let (override_captures, override_exports) = build_override_exports(&override_env); let (proxy_captures, proxy_exports) = build_proxy_env_exports(); + let runtime_path_prepend_exports = + runtime_path_prepends.shell_exports_after_snapshot(explicit_env_overrides); let override_captures = join_shell_blocks([override_captures, proxy_captures]); - let override_exports = join_shell_blocks([override_exports, proxy_exports]); + let override_exports = join_shell_blocks([ + override_exports, + proxy_exports, + runtime_path_prepend_exports, + ]); let rewritten_script = if override_exports.is_empty() { format!( "if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}" diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 369f301cd7..011da46b3d 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -166,23 +166,106 @@ fn explicit_escalation_preserves_user_ca_env() { ); } +#[cfg(unix)] +#[test] +fn runtime_path_prepends_records_runtime_path_prepend() { + let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); + let mut runtime_path_prepends = RuntimePathPrepends::default(); + + runtime_path_prepends.prepend(&mut env, PathBuf::from("/package/codex-path").as_path()); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/package/codex-path:/usr/bin:/bin"), + "runtime PATH prepend should update the live exec environment" + ); + assert_eq!( + runtime_path_prepends.entries, + vec!["/package/codex-path"], + "runtime PATH prepend should be recorded for snapshot replay" + ); +} + +#[cfg(unix)] +#[test] +fn runtime_path_prepends_drops_empty_path_entries() { + let mut env = HashMap::from([( + "PATH".to_string(), + ":/usr/bin:/package/codex-path::/bin:".to_string(), + )]); + let mut runtime_path_prepends = RuntimePathPrepends::default(); + + runtime_path_prepends.prepend(&mut env, PathBuf::from("/package/codex-path").as_path()); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/package/codex-path:/usr/bin:/bin"), + "empty PATH entries should be dropped instead of preserving current-directory lookup" + ); + assert_eq!( + runtime_path_prepends.entries, + vec!["/package/codex-path"], + "deduped runtime PATH prepend should still be recorded once" + ); +} + +#[cfg(unix)] +#[test] +fn runtime_path_prepends_ignores_empty_path_entry() { + let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); + let mut runtime_path_prepends = RuntimePathPrepends::default(); + + runtime_path_prepends.prepend(&mut env, PathBuf::new().as_path()); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/usr/bin:/bin"), + "empty runtime PATH prepend should leave PATH unchanged" + ); + assert_eq!( + runtime_path_prepends, + RuntimePathPrepends::default(), + "empty runtime PATH prepend should not be recorded for snapshot replay" + ); +} + +#[cfg(unix)] +#[test] +fn prepend_zsh_fork_bin_to_path_ignores_empty_parent() { + let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); + + let result = prepend_zsh_fork_bin_to_path(&mut env, PathBuf::from("zsh").as_path()); + + assert_eq!( + result, None, + "zsh fork helper should not report a PATH update for an empty parent" + ); + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/usr/bin:/bin"), + "zsh fork helper should leave PATH unchanged when the parent is empty" + ); +} + #[cfg(unix)] #[test] fn apply_zsh_fork_path_prepend_uses_shell_parent() { let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); - let mut explicit_env_overrides = HashMap::new(); + let mut runtime_path_prepends = RuntimePathPrepends::default(); apply_zsh_fork_path_prepend( &mut env, - &mut explicit_env_overrides, + &mut runtime_path_prepends, PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), ); let expected = "/package/codex-resources/zsh/bin:/usr/bin:/bin"; assert_eq!(env.get("PATH").map(String::as_str), Some(expected)); assert_eq!( - explicit_env_overrides.get("PATH").map(String::as_str), - Some(expected) + runtime_path_prepends, + RuntimePathPrepends { + entries: vec!["/package/codex-resources/zsh/bin".to_string()] + } ); } @@ -194,11 +277,11 @@ fn apply_zsh_fork_path_prepend_moves_existing_shell_parent_to_front() { "/usr/bin:/package/codex-resources/zsh/bin:/bin:/package/codex-resources/zsh/bin" .to_string(), )]); - let mut explicit_env_overrides = HashMap::new(); + let mut runtime_path_prepends = RuntimePathPrepends::default(); apply_zsh_fork_path_prepend( &mut env, - &mut explicit_env_overrides, + &mut runtime_path_prepends, PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), ); @@ -206,6 +289,12 @@ fn apply_zsh_fork_path_prepend_moves_existing_shell_parent_to_front() { env.get("PATH").map(String::as_str), Some("/package/codex-resources/zsh/bin:/usr/bin:/bin") ); + assert_eq!( + runtime_path_prepends, + RuntimePathPrepends { + entries: vec!["/package/codex-resources/zsh/bin".to_string()] + } + ); } #[test] @@ -250,6 +339,7 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert_eq!(rewritten[0], "/bin/zsh"); @@ -281,6 +371,7 @@ fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert!(rewritten[2].contains(r#"exec '/bin/bash' -c 'echo '"'"'hello'"'"''"#)); @@ -309,6 +400,7 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert_eq!(rewritten[0], "/bin/bash"); @@ -340,6 +432,7 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert_eq!(rewritten[0], "/bin/sh"); @@ -373,6 +466,7 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert!( @@ -408,6 +502,7 @@ fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() { &command_cwd.abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert_eq!(rewritten, command); @@ -437,6 +532,7 @@ fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() { &command_cwd.abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); assert_eq!(rewritten[0], "/bin/zsh"); @@ -473,6 +569,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() { &dir.path().abs(), &explicit_env_overrides, &HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -513,6 +610,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() { &dir.path().abs(), &HashMap::new(), &HashMap::from([("CODEX_THREAD_ID".to_string(), "nested-thread".to_string())]), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -555,6 +653,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -612,6 +711,7 @@ fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -657,6 +757,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_custom_git_ssh_command() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -703,6 +804,7 @@ fn maybe_wrap_shell_lc_with_snapshot_clears_stale_codex_git_ssh_command_without_ &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -740,6 +842,7 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_user_proxy_env_when_proxy_inactive() &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); let mut command = Command::new(&rewritten[0]); command.args(&rewritten[1..]); @@ -793,6 +896,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_live_env_when_snapshot_proxy_activ "HTTP_PROXY".to_string(), "http://user.proxy:8080".to_string(), )]), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -835,6 +939,7 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() { &dir.path().abs(), &HashMap::new(), &HashMap::new(), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -872,6 +977,7 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { &dir.path().abs(), &explicit_env_overrides, &HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -883,6 +989,84 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); } +#[cfg(unix)] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_package_path_prepend() -> anyhow::Result<()> { + let (stdout, package_path_dir) = + run_snapshot_path_probe_with_runtime_path_prepend(HashMap::new())?; + + assert_eq!( + stdout, + format!("{}:/snapshot/bin", package_path_dir.display()), + "package path prepend should replay ahead of snapshot PATH" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_applies_runtime_path_prepend_after_explicit_path_override() +-> anyhow::Result<()> { + let (stdout, package_path_dir) = run_snapshot_path_probe_with_runtime_path_prepend( + HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]), + )?; + + assert_eq!( + stdout, + format!("{}:/worktree/bin", package_path_dir.display()), + "explicit PATH override should suppress snapshot PATH while preserving runtime prepend" + ); + Ok(()) +} + +#[cfg(unix)] +fn run_snapshot_path_probe_with_runtime_path_prepend( + explicit_env_overrides: HashMap, +) -> anyhow::Result<(String, PathBuf)> { + let dir = tempdir()?; + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + )?; + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let package_path_dir = dir.path().join("codex-path"); + let mut env = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let mut runtime_path_prepends = RuntimePathPrepends::default(); + runtime_path_prepends.prepend(&mut env, package_path_dir.as_path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &explicit_env_overrides, + &env, + &runtime_path_prepends, + ); + let path = env + .get("PATH") + .ok_or_else(|| anyhow::anyhow!("PATH should be set"))?; + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", path) + .output()?; + + assert!(output.status.success(), "command failed: {output:?}"); + Ok(( + String::from_utf8_lossy(&output.stdout).into_owned(), + package_path_dir, + )) +} + #[cfg(unix)] #[test] fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() { @@ -912,14 +1096,16 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() { .join("zsh"); let zsh_bin_dir = zsh_path.parent().expect("zsh path should have parent"); let mut env = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); - let mut explicit_env_overrides = HashMap::new(); - apply_zsh_fork_path_prepend(&mut env, &mut explicit_env_overrides, zsh_path.as_path()); + let explicit_env_overrides = HashMap::new(); + let mut runtime_path_prepends = RuntimePathPrepends::default(); + apply_zsh_fork_path_prepend(&mut env, &mut runtime_path_prepends, zsh_path.as_path()); let rewritten = maybe_wrap_shell_lc_with_snapshot( &command, &session_shell, &dir.path().abs(), &explicit_env_overrides, &env, + &runtime_path_prepends, ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -930,7 +1116,8 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() { assert!(output.status.success(), "command failed: {output:?}"); assert_eq!( String::from_utf8_lossy(&output.stdout), - format!("{}:/worktree/bin", zsh_bin_dir.display()) + format!("{}:/snapshot/bin", zsh_bin_dir.display()), + "zsh fork path prepend should replay ahead of snapshot PATH" ); } @@ -967,6 +1154,7 @@ fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { "OPENAI_API_KEY".to_string(), "super-secret-value".to_string(), )]), + &RuntimePathPrepends::default(), ); assert!(!rewritten[2].contains("super-secret-value")); @@ -1012,6 +1200,7 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() { &dir.path().abs(), &explicit_env_overrides, &HashMap::new(), + &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0380f1840d..13b65f0443 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -20,6 +20,7 @@ use crate::shell::ShellType; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; +use crate::tools::runtimes::RuntimePathPrepends; #[cfg(unix)] use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::build_sandbox_command; @@ -249,22 +250,29 @@ impl ToolRuntime for ShellRuntime { let env = exec_env_for_sandbox_permissions(&req.env, sandbox_permissions); let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] - let (env, explicit_env_overrides) = { + let (env, runtime_path_prepends) = { let mut env = env; - let mut explicit_env_overrides = explicit_env_overrides; + let mut runtime_path_prepends = RuntimePathPrepends::default(); + crate::tools::runtimes::apply_package_path_prepend( + &mut env, + &mut runtime_path_prepends, + ); if self.backend == ShellRuntimeBackend::ShellCommandZshFork && let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_deref() { - apply_zsh_fork_path_prepend(&mut env, &mut explicit_env_overrides, shell_zsh_path); + apply_zsh_fork_path_prepend(&mut env, &mut runtime_path_prepends, shell_zsh_path); } - (env, explicit_env_overrides) + (env, runtime_path_prepends) }; + #[cfg(not(unix))] + let runtime_path_prepends = RuntimePathPrepends::default(); let command = maybe_wrap_shell_lc_with_snapshot( &req.command, session_shell.as_ref(), &req.cwd, &explicit_env_overrides, &env, + &runtime_path_prepends, ); let command = disable_powershell_profile_for_elevated_windows_sandbox( &command, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index e49c925728..bf04a6fe35 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -17,6 +17,7 @@ use crate::shell::ShellType; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; +use crate::tools::runtimes::RuntimePathPrepends; #[cfg(unix)] use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::build_sandbox_command; @@ -278,20 +279,28 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = managed_network { network.apply_to_env(&mut env); } + let environment_is_remote = req.environment.is_remote(); let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] - let explicit_env_overrides = { - let mut explicit_env_overrides = explicit_env_overrides; + let runtime_path_prepends = { + let mut runtime_path_prepends = RuntimePathPrepends::default(); + if !environment_is_remote { + crate::tools::runtimes::apply_package_path_prepend( + &mut env, + &mut runtime_path_prepends, + ); + } if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { apply_zsh_fork_path_prepend( &mut env, - &mut explicit_env_overrides, + &mut runtime_path_prepends, zsh_fork_config.shell_zsh_path.as_path(), ); } - explicit_env_overrides + runtime_path_prepends }; - let environment_is_remote = req.environment.is_remote(); + #[cfg(not(unix))] + let runtime_path_prepends = RuntimePathPrepends::default(); let command = if environment_is_remote { base_command.to_vec() } else { @@ -301,6 +310,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.cwd, &explicit_env_overrides, &env, + &runtime_path_prepends, ) }; let command = disable_powershell_profile_for_elevated_windows_sandbox( diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index d92a80cbef..63694c5d62 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -184,11 +184,14 @@ impl InstallContext { impl CodexPackageLayout { fn from_exe(exe_path: &Path) -> Option { let canonical_exe = canonical_absolute_path(exe_path)?; - let bin_dir = canonical_exe.parent()?; - if bin_dir.file_name() != Some(OsStr::new(BIN_DIRNAME)) { - return None; + let exe_dir = canonical_exe.parent()?; + match exe_dir.file_name() { + Some(name) if name == OsStr::new(BIN_DIRNAME) => Self::from_package_bin_dir(exe_dir), + Some(_) | None => None, } + } + fn from_package_bin_dir(bin_dir: AbsolutePathBuf) -> Option { let package_dir = bin_dir.parent()?; if !package_dir.join(PACKAGE_METADATA_FILENAME).is_file() { return None; From c143a86de8c5da3ae5127565b9ffe7094d2e5c28 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 3 Jun 2026 20:00:44 -0700 Subject: [PATCH 018/141] [codex-analytics] report compaction request token counts (#25946) ## Why Compaction analytics need token counts that better represent the request being compacted. The existing session snapshot can diverge from the actual remote compaction request after output rewriting, and remote v2 can use server-side Responses usage when available. ## What changed - Add an optional `active_context_tokens_before` override to `CompactionAnalyticsAttempt::track(...)` for remote compaction when it has a better before-token value than the begin-time session snapshot. The local `/compact` path passes no override. - For remote v1 `responses_compact`, subtract the estimated token delta from pre-compaction output rewriting from the session snapshot, capped by locally-added tokens since the last successful API response. - For remote v2 `responses_compaction_v2`, use the same bounded output-rewrite fallback as remote v1, then overwrite `active_context_tokens_before` with server `token_usage.input_tokens` from the `response.completed` event when present. - Keep the existing v2 compaction-output validation while carrying the completed response token usage through `collect_compaction_output`. ## Verification - `just fmt` - `just test -p codex-core collect_compaction_output_accepts_additional_output_items` - `git diff --check` --- codex-rs/core/src/compact.rs | 30 +++++++- codex-rs/core/src/compact_remote.rs | 63 +++++++++++---- codex-rs/core/src/compact_remote_v2.rs | 101 ++++++++++++++++++++----- 3 files changed, 159 insertions(+), 35 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4ff35c09e4..4dc4c34e99 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -146,7 +146,12 @@ async fn run_compact_task_inner( PreCompactHookOutcome::Stopped { reason } => { let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); attempt - .track(sess.as_ref(), CompactionStatus::Interrupted, Some(error)) + .track( + sess.as_ref(), + CompactionStatus::Interrupted, + Some(error), + /*active_context_tokens_before*/ None, + ) .await; return Err(CodexErr::TurnAborted); } @@ -164,11 +169,25 @@ async fn run_compact_task_inner( if result.is_ok() { let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { - attempt.track(sess.as_ref(), status, error).await; + attempt + .track( + sess.as_ref(), + status, + error, + /*active_context_tokens_before*/ None, + ) + .await; return Err(CodexErr::TurnAborted); } } - attempt.track(sess.as_ref(), status, error).await; + attempt + .track( + sess.as_ref(), + status, + error, + /*active_context_tokens_before*/ None, + ) + .await; result.map(|_| ()) } @@ -344,7 +363,10 @@ impl CompactionAnalyticsAttempt { sess: &Session, status: CompactionStatus, error: Option, + active_context_tokens_before: Option, ) { + let active_context_tokens_before = + active_context_tokens_before.unwrap_or(self.active_context_tokens_before); let active_context_tokens_after = sess.get_total_token_usage().await; sess.services .analytics_events_client @@ -358,7 +380,7 @@ impl CompactionAnalyticsAttempt { strategy: CompactionStrategy::Memento, status, error, - active_context_tokens_before: self.active_context_tokens_before, + active_context_tokens_before, active_context_tokens_after, started_at: self.started_at, completed_at: now_unix_seconds(), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index cbac86fb91..444b1c3a69 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -100,6 +100,7 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompact, phase, ); + let mut active_context_tokens_before = sess.get_total_token_usage().await; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -119,6 +120,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), + Some(active_context_tokens_before), ) .await; return Err(CodexErr::TurnAborted); @@ -129,6 +131,7 @@ async fn run_remote_compact_task_inner( turn_context, initial_context_injection, compaction_metadata, + &mut active_context_tokens_before, ) .await; let status = compaction_status_from_result(&result); @@ -136,11 +139,25 @@ async fn run_remote_compact_task_inner( if result.is_ok() { let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { - attempt.track(sess.as_ref(), status, error).await; + attempt + .track( + sess.as_ref(), + status, + error, + Some(active_context_tokens_before), + ) + .await; return Err(CodexErr::TurnAborted); } } - attempt.track(sess.as_ref(), status, error.clone()).await; + attempt + .track( + sess.as_ref(), + status, + error.clone(), + Some(active_context_tokens_before), + ) + .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( @@ -157,6 +174,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, + active_context_tokens_before: &mut i64, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -172,11 +190,12 @@ async fn run_remote_compact_task_inner_impl( .await; let mut history = sess.clone_history().await; let base_instructions = sess.get_base_instructions().await; - let rewritten_outputs = trim_function_call_history_to_fit_context_window( - &mut history, - turn_context.as_ref(), - &base_instructions, - ); + let (rewritten_outputs, estimated_deleted_tokens) = + trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); if rewritten_outputs > 0 { info!( turn_id = %turn_context.sub_id, @@ -184,6 +203,14 @@ async fn run_remote_compact_task_inner_impl( "rewrote history outputs before remote compaction" ); } + if estimated_deleted_tokens > 0 { + let max_local_deleted_tokens = sess + .get_total_token_usage_breakdown() + .await + .estimated_tokens_of_items_added_since_last_successful_api_response; + *active_context_tokens_before = (*active_context_tokens_before) + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + } // This is the history selected for remote compaction, after any output rewriting required to // fit the compact endpoint. The checkpoint below records it separately from the next sampling // request, whose prompt will repeat current developer/context prefix items. @@ -382,18 +409,21 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, base_instructions: &BaseInstructions, -) -> usize { +) -> (usize, i64) { let Some(context_window) = turn_context.model_context_window() else { - return 0; + return (0, 0); }; let mut rewritten_outputs = 0usize; + let mut estimated_deleted_tokens = 0i64; let item_count = history.raw_items().len(); for index in (0..item_count).rev() { - if history - .estimate_token_count_with_base_instructions(base_instructions) - .is_none_or(|estimated_tokens| estimated_tokens <= context_window) - { + let Some(estimated_tokens_before) = + history.estimate_token_count_with_base_instructions(base_instructions) + else { + break; + }; + if estimated_tokens_before <= context_window { break; } let Some(rewritten_item) = history @@ -406,10 +436,15 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( let mut items = history.raw_items().to_vec(); items[index] = rewritten_item; history.replace(items); + let estimated_tokens_after = history + .estimate_token_count_with_base_instructions(base_instructions) + .unwrap_or_default(); rewritten_outputs += 1; + estimated_deleted_tokens = estimated_deleted_tokens + .saturating_add(estimated_tokens_before.saturating_sub(estimated_tokens_after)); } - rewritten_outputs + (rewritten_outputs, estimated_deleted_tokens) } fn rewritten_output_for_context_window(item: &ResponseItem) -> Option { diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 77ef1629c3..ac64f7f0f2 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -35,6 +35,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; @@ -112,6 +113,7 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompactionV2, phase, ); + let mut active_context_tokens_before = sess.get_total_token_usage().await; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -131,6 +133,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), + Some(active_context_tokens_before), ) .await; return Err(CodexErr::TurnAborted); @@ -142,6 +145,7 @@ async fn run_remote_compact_task_inner( client_session, initial_context_injection, compaction_metadata, + &mut active_context_tokens_before, ) .await; let status = compaction_status_from_result(&result); @@ -149,11 +153,25 @@ async fn run_remote_compact_task_inner( if result.is_ok() { let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { - attempt.track(sess.as_ref(), status, error).await; + attempt + .track( + sess.as_ref(), + status, + error, + Some(active_context_tokens_before), + ) + .await; return Err(CodexErr::TurnAborted); } } - attempt.track(sess.as_ref(), status, error.clone()).await; + attempt + .track( + sess.as_ref(), + status, + error.clone(), + Some(active_context_tokens_before), + ) + .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( @@ -171,6 +189,7 @@ async fn run_remote_compact_task_inner_impl( client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, + active_context_tokens_before: &mut i64, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -185,11 +204,12 @@ async fn run_remote_compact_task_inner_impl( let mut history = sess.clone_history().await; let base_instructions = sess.get_base_instructions().await; - let rewritten_outputs = trim_function_call_history_to_fit_context_window( - &mut history, - turn_context.as_ref(), - &base_instructions, - ); + let (rewritten_outputs, estimated_deleted_tokens) = + trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); if rewritten_outputs > 0 { info!( turn_id = %turn_context.sub_id, @@ -197,6 +217,14 @@ async fn run_remote_compact_task_inner_impl( "rewrote history outputs before remote compaction v2" ); } + if estimated_deleted_tokens > 0 { + let max_local_deleted_tokens = sess + .get_total_token_usage_breakdown() + .await + .estimated_tokens_of_items_added_since_last_successful_api_response; + *active_context_tokens_before = (*active_context_tokens_before) + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + } let trace_input_history = history.raw_items().to_vec(); let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); @@ -249,9 +277,16 @@ async fn run_remote_compact_task_inner_impl( trace_attempt.record_result( compaction_output_result .as_ref() - .map(|(item, _)| std::slice::from_ref(item)), + .map(|output| std::slice::from_ref(&output.compaction_output)), ); - let (compaction_output, response_id) = compaction_output_result?; + let RemoteCompactionV2Output { + compaction_output, + response_id, + token_usage, + } = compaction_output_result?; + if let Some(token_usage) = token_usage { + *active_context_tokens_before = token_usage.input_tokens; + } let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output); let new_history = process_compacted_history( sess.as_ref(), @@ -288,13 +323,19 @@ async fn run_remote_compact_task_inner_impl( Ok(()) } +struct RemoteCompactionV2Output { + compaction_output: ResponseItem, + response_id: String, + token_usage: Option, +} + async fn run_remote_compaction_request_v2( sess: &Session, turn_context: &TurnContext, client_session: &mut ModelClientSession, prompt: &Prompt, turn_metadata_header: Option<&str>, -) -> CodexResult<(ResponseItem, String)> { +) -> CodexResult { let max_retries = turn_context .provider .info() @@ -364,11 +405,12 @@ async fn log_remote_compaction_request_failure( async fn collect_compaction_output( mut stream: ResponseStream, -) -> CodexResult<(ResponseItem, String)> { +) -> CodexResult { let mut output_item_count = 0usize; let mut compaction_count = 0usize; let mut compaction_output = None; let mut completed_response_id = None; + let mut completed_token_usage = None; while let Some(event) = stream.next().await { match event? { ResponseEvent::OutputItemDone(item) => { @@ -380,8 +422,13 @@ async fn collect_compaction_output( } } } - ResponseEvent::Completed { response_id, .. } => { + ResponseEvent::Completed { + response_id, + token_usage, + .. + } => { completed_response_id = Some(response_id); + completed_token_usage = token_usage; break; } _ => {} @@ -404,7 +451,11 @@ async fn collect_compaction_output( let Some(compaction_output) = compaction_output else { unreachable!("compaction output must exist when count is exactly one"); }; - Ok((compaction_output, response_id)) + Ok(RemoteCompactionV2Output { + compaction_output, + response_id, + token_usage: completed_token_usage, + }) } fn build_v2_compacted_history( @@ -737,16 +788,32 @@ mod tests { Ok(ResponseEvent::OutputItemDone(compaction.clone())), Ok(ResponseEvent::Completed { response_id: "resp-compact".to_string(), - token_usage: None, + token_usage: Some(TokenUsage { + input_tokens: 123_456, + cached_input_tokens: 7_890, + output_tokens: 42, + reasoning_output_tokens: 5, + total_tokens: 123_498, + }), end_turn: Some(true), }), ]); - let (output, response_id) = collect_compaction_output(stream) + let output = collect_compaction_output(stream) .await .expect("compaction should be collected"); - assert_eq!(output, compaction); - assert_eq!(response_id, "resp-compact"); + assert_eq!(output.compaction_output, compaction); + assert_eq!(output.response_id, "resp-compact"); + assert_eq!( + output.token_usage, + Some(TokenUsage { + input_tokens: 123_456, + cached_input_tokens: 7_890, + output_tokens: 42, + reasoning_output_tokens: 5, + total_tokens: 123_498, + }) + ); } } From ad2012d645b7146d31bb03f98e2bd9371635d11a Mon Sep 17 00:00:00 2001 From: Eric Burke <163169620+eburke-openai@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:34:51 -0400 Subject: [PATCH 019/141] ci: sign macOS release artifacts with Azure Key Vault (#26252) ## Why The public Codex release workflow needs to sign and notarize macOS binaries and DMGs without placing the Developer ID private key in GitHub. This moves the private-key operation behind the protected `codesigning` environment and uses GitHub OIDC with Azure Key Vault PKCS#11, while preserving the existing external `build_unsigned` / `promote_signed` fallback. ## What changed - Add a reusable AKV PKCS11 setup action that authenticates to Azure with OIDC, downloads pinned signing tools, verifies their SHA-256 digests, and loads the public signing certificate from Key Vault. - Replace the legacy macOS signing action with scripts that support AKV-backed `rcodesign`, notarize signed binaries and DMGs, and staple DMG notarization tickets. - Restructure `rust-release.yml` so macOS builds produce unsigned artifacts first, protected jobs perform signing and notarization, macOS runners package and verify the results, and release publishing waits for verified artifacts. - Preserve the manual external-signing handoff flow and make manual-mode conditions explicit. - Move the Codex entitlements file alongside the signing scripts and update CODEOWNERS for the new signing surfaces. ## Verification - [Live protected signing workflow run](https://github.com/openai/codex/actions/runs/26903610631) completed successfully for both macOS architectures, including binary signing/notarization, DMG signing/notarization, and final artifact verification. - Downloaded both signed DMGs and independently verified their checksums and strict signatures. - Confirmed `xcrun stapler validate` succeeds and Gatekeeper accepts both DMGs as `Notarized Developer ID`. - Mounted both DMGs and confirmed the contained `codex` and `codex-responses-api-proxy` binaries have valid Developer ID signatures for the expected architectures. --------- Co-authored-by: shijie-openai --- .github/CODEOWNERS | 5 + .github/actions/macos-code-sign/action.yml | 259 ------- .../actions/macos-code-sign/notary_helpers.sh | 46 -- .../setup-akv-pkcs11-codesigning/action.yaml | 349 +++++++++ .../macos-signing}/codex.entitlements.plist | 0 .../notarize_macos_binary_with_rcodesign.sh | 131 ++++ .../notarize_macos_dmg_with_rcodesign.sh | 124 ++++ .../scripts/macos-signing/sign_macos_code.sh | 245 +++++++ .github/workflows/rust-release.yml | 693 +++++++++++++++--- 9 files changed, 1464 insertions(+), 388 deletions(-) delete mode 100644 .github/actions/macos-code-sign/action.yml delete mode 100644 .github/actions/macos-code-sign/notary_helpers.sh create mode 100644 .github/actions/setup-akv-pkcs11-codesigning/action.yaml rename .github/{actions/macos-code-sign => scripts/macos-signing}/codex.entitlements.plist (100%) create mode 100755 .github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh create mode 100755 .github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh create mode 100755 .github/scripts/macos-signing/sign_macos_code.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 668897c48e..d29c06e6f0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,5 +3,10 @@ /codex-rs/ext/extension-api/ @openai/codex-core-agent-team /codex-rs/prompts/ @openai/codex-core-agent-team +# Keep macOS AKV signing changes reviewed by Codex maintainers. +/.github/actions/setup-akv-pkcs11-codesigning/ @openai/codex-core-agent-team +/.github/scripts/macos-signing/ @openai/codex-core-agent-team +/.github/workflows/rust-release.yml @openai/codex-core-agent-team + # Keep ownership changes reviewed by the same team. /.github/CODEOWNERS @openai/codex-core-agent-team diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml deleted file mode 100644 index 0e19fa11d0..0000000000 --- a/.github/actions/macos-code-sign/action.yml +++ /dev/null @@ -1,259 +0,0 @@ -name: macos-code-sign -description: Configure, sign, notarize, and clean up macOS code signing artifacts. -inputs: - target: - description: Rust compilation target triple (e.g. aarch64-apple-darwin). - required: true - binaries: - description: Space-delimited binary basenames to sign and notarize. - default: "codex codex-responses-api-proxy" - sign-binaries: - description: Whether to sign and notarize the macOS binaries. - required: false - default: "true" - sign-dmg: - description: Whether to sign and notarize the macOS dmg. - required: false - default: "true" - apple-certificate: - description: Base64-encoded Apple signing certificate (P12). - required: true - apple-certificate-password: - description: Password for the signing certificate. - required: true - apple-notarization-key-p8: - description: Base64-encoded Apple notarization key (P8). - required: true - apple-notarization-key-id: - description: Apple notarization key ID. - required: true - apple-notarization-issuer-id: - description: Apple notarization issuer ID. - required: true -runs: - using: composite - steps: - - name: Configure Apple code signing - shell: bash - env: - KEYCHAIN_PASSWORD: actions - APPLE_CERTIFICATE: ${{ inputs.apple-certificate }} - APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple-certificate-password }} - run: | - set -euo pipefail - - if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then - echo "APPLE_CERTIFICATE is required for macOS signing" - exit 1 - fi - - if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then - echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing" - exit 1 - fi - - cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12" - echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path" - - keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" - security set-keychain-settings -lut 21600 "$keychain_path" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" - - keychain_args=() - cleanup_keychain() { - if ((${#keychain_args[@]} > 0)); then - security list-keychains -s "${keychain_args[@]}" || true - security default-keychain -s "${keychain_args[0]}" || true - else - security list-keychains -s || true - fi - if [[ -f "$keychain_path" ]]; then - security delete-keychain "$keychain_path" || true - fi - } - - while IFS= read -r keychain; do - [[ -n "$keychain" ]] && keychain_args+=("$keychain") - done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') - - if ((${#keychain_args[@]} > 0)); then - security list-keychains -s "$keychain_path" "${keychain_args[@]}" - else - security list-keychains -s "$keychain_path" - fi - - security default-keychain -s "$keychain_path" - security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null - - codesign_hashes=() - while IFS= read -r hash; do - [[ -n "$hash" ]] && codesign_hashes+=("$hash") - done < <(security find-identity -v -p codesigning "$keychain_path" \ - | sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \ - | sort -u) - - if ((${#codesign_hashes[@]} == 0)); then - echo "No signing identities found in $keychain_path" - cleanup_keychain - rm -f "$cert_path" - exit 1 - fi - - if ((${#codesign_hashes[@]} > 1)); then - echo "Multiple signing identities found in $keychain_path:" - printf ' %s\n' "${codesign_hashes[@]}" - cleanup_keychain - rm -f "$cert_path" - exit 1 - fi - - APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}" - - rm -f "$cert_path" - - echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV" - echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" - echo "::add-mask::$APPLE_CODESIGN_IDENTITY" - - - name: Sign macOS binaries - if: ${{ inputs.sign-binaries == 'true' }} - shell: bash - env: - TARGET: ${{ inputs.target }} - BINARIES: ${{ inputs.binaries }} - run: | - set -euo pipefail - - if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then - echo "APPLE_CODESIGN_IDENTITY is required for macOS signing" - exit 1 - fi - - keychain_args=() - if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then - keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") - fi - - entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" - - for binary in ${BINARIES}; do - path="codex-rs/target/${TARGET}/release/${binary}" - codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" - done - - - name: Notarize macOS binaries - if: ${{ inputs.sign-binaries == 'true' }} - shell: bash - env: - TARGET: ${{ inputs.target }} - BINARIES: ${{ inputs.binaries }} - APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }} - run: | - set -euo pipefail - - for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do - if [[ -z "${!var:-}" ]]; then - echo "$var is required for notarization" - exit 1 - fi - done - - notary_key_path="${RUNNER_TEMP}/notarytool.key.p8" - echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path" - cleanup_notary() { - rm -f "$notary_key_path" - } - trap cleanup_notary EXIT - - source "$GITHUB_ACTION_PATH/notary_helpers.sh" - - notarize_binary() { - local binary="$1" - local source_path="codex-rs/target/${TARGET}/release/${binary}" - local archive_path="${RUNNER_TEMP}/${binary}.zip" - - if [[ ! -f "$source_path" ]]; then - echo "Binary $source_path not found" - exit 1 - fi - - rm -f "$archive_path" - ditto -c -k --keepParent "$source_path" "$archive_path" - - notarize_submission "$binary" "$archive_path" "$notary_key_path" - } - - for binary in ${BINARIES}; do - notarize_binary "${binary}" - done - - - name: Sign and notarize macOS dmg - if: ${{ inputs.sign-dmg == 'true' }} - shell: bash - env: - TARGET: ${{ inputs.target }} - APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }} - run: | - set -euo pipefail - - for var in APPLE_CODESIGN_IDENTITY APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do - if [[ -z "${!var:-}" ]]; then - echo "$var is required" - exit 1 - fi - done - - notary_key_path="${RUNNER_TEMP}/notarytool.key.p8" - echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path" - cleanup_notary() { - rm -f "$notary_key_path" - } - trap cleanup_notary EXIT - - source "$GITHUB_ACTION_PATH/notary_helpers.sh" - - dmg_name="codex-${TARGET}.dmg" - dmg_path="codex-rs/target/${TARGET}/release/${dmg_name}" - - if [[ ! -f "$dmg_path" ]]; then - echo "dmg $dmg_path not found" - exit 1 - fi - - keychain_args=() - if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then - keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") - fi - - codesign --force --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$dmg_path" - notarize_submission "$dmg_name" "$dmg_path" "$notary_key_path" - xcrun stapler staple "$dmg_path" - - - name: Remove signing keychain - if: ${{ always() }} - shell: bash - env: - APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }} - run: | - set -euo pipefail - if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then - keychain_args=() - while IFS= read -r keychain; do - [[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue - [[ -n "$keychain" ]] && keychain_args+=("$keychain") - done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') - if ((${#keychain_args[@]} > 0)); then - security list-keychains -s "${keychain_args[@]}" - security default-keychain -s "${keychain_args[0]}" - fi - - if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then - security delete-keychain "$APPLE_CODESIGN_KEYCHAIN" - fi - fi diff --git a/.github/actions/macos-code-sign/notary_helpers.sh b/.github/actions/macos-code-sign/notary_helpers.sh deleted file mode 100644 index ad9757fe3c..0000000000 --- a/.github/actions/macos-code-sign/notary_helpers.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash - -notarize_submission() { - local label="$1" - local path="$2" - local notary_key_path="$3" - - if [[ -z "${APPLE_NOTARIZATION_KEY_ID:-}" || -z "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then - echo "APPLE_NOTARIZATION_KEY_ID and APPLE_NOTARIZATION_ISSUER_ID are required for notarization" - exit 1 - fi - - if [[ -z "$notary_key_path" || ! -f "$notary_key_path" ]]; then - echo "Notary key file $notary_key_path not found" - exit 1 - fi - - if [[ ! -f "$path" ]]; then - echo "Notarization payload $path not found" - exit 1 - fi - - local submission_json - submission_json=$(xcrun notarytool submit "$path" \ - --key "$notary_key_path" \ - --key-id "$APPLE_NOTARIZATION_KEY_ID" \ - --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ - --output-format json \ - --wait) - - local status submission_id - status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"') - submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""') - - if [[ -z "$submission_id" ]]; then - echo "Failed to retrieve submission ID for $label" - exit 1 - fi - - echo "::notice title=Notarization::$label submission ${submission_id} completed with status ${status}" - - if [[ "$status" != "Accepted" ]]; then - echo "Notarization failed for ${label} (submission ${submission_id}, status ${status})" - exit 1 - fi -} diff --git a/.github/actions/setup-akv-pkcs11-codesigning/action.yaml b/.github/actions/setup-akv-pkcs11-codesigning/action.yaml new file mode 100644 index 0000000000..4d79cab42e --- /dev/null +++ b/.github/actions/setup-akv-pkcs11-codesigning/action.yaml @@ -0,0 +1,349 @@ +name: Set up AKV PKCS11 code signing +description: Download prebuilt rcodesign and Azure Key Vault PKCS11 provider artifacts, then export macOS signing environment. + +inputs: + setup-mode: + description: signing configures Azure and exports signing env vars; tools-only only downloads signing tools. + required: false + default: signing + rcodesign-blob-uri: + description: Azure Blob URI for the prebuilt Linux/amd64 rcodesign binary. + required: true + rcodesign-sha256: + description: Expected SHA-256 digest for the prebuilt rcodesign binary. + required: true + akv-pkcs11-library-blob-uri: + description: Azure Blob URI for the prebuilt Linux/amd64 AKV PKCS11 provider library. + required: true + akv-pkcs11-library-sha256: + description: Expected SHA-256 digest for the prebuilt AKV PKCS11 provider library. + required: true + azure-client-id: + description: GitHub OIDC client ID for the Azure signer application. + required: true + azure-tenant-id: + description: Azure tenant ID for the signer application. + required: true + azure-subscription-id: + description: Azure subscription ID that owns the signing vault. + required: true + key-vault-name: + description: Azure Key Vault name containing the certificate-backed signing key. + required: true + key-name: + description: Key Vault certificate/key name used as the PKCS11 key label. + required: true + key-version: + description: Optional Key Vault key version to pin while signing. + required: false + default: "" + certificate-sha256: + description: Optional expected SHA-256 fingerprint for the downloaded public certificate. + required: false + default: "" + +outputs: + pkcs11-library: + description: Path to the downloaded AKV PKCS11 provider library. + value: ${{ steps.paths.outputs.pkcs11_library }} + signing-certificate-pem: + description: Path to the downloaded public signing certificate. + value: ${{ steps.paths.outputs.signing_certificate_pem }} + rcodesign: + description: Path to the downloaded rcodesign binary. + value: ${{ steps.paths.outputs.rcodesign }} + +runs: + using: composite + steps: + - name: Validate pinned signing artifacts + shell: bash + env: + SETUP_MODE: ${{ inputs.setup-mode }} + RCODESIGN_BLOB_URI: ${{ inputs.rcodesign-blob-uri }} + RCODESIGN_SHA256: ${{ inputs.rcodesign-sha256 }} + AKV_PKCS11_LIBRARY_BLOB_URI: ${{ inputs.akv-pkcs11-library-blob-uri }} + AKV_PKCS11_LIBRARY_SHA256: ${{ inputs.akv-pkcs11-library-sha256 }} + KEY_VAULT_NAME: ${{ inputs.key-vault-name }} + KEY_NAME: ${{ inputs.key-name }} + run: | + set -euo pipefail + + case "$SETUP_MODE" in + signing|tools-only) + ;; + *) + echo "setup-mode must be 'signing' or 'tools-only', got '$SETUP_MODE'." >&2 + exit 1 + ;; + esac + + for variable_name in RCODESIGN_SHA256 AKV_PKCS11_LIBRARY_SHA256; do + value="${!variable_name}" + if [[ ! "$value" =~ ^[0-9a-f]{64}$ ]]; then + echo "$variable_name must be a lowercase SHA-256 digest." >&2 + exit 1 + fi + done + + for variable_name in RCODESIGN_BLOB_URI AKV_PKCS11_LIBRARY_BLOB_URI; do + value="${!variable_name}" + if [[ ! "$value" =~ ^az://[^/]+/[^/]+/.+ ]]; then + echo "$variable_name must use az:////." >&2 + exit 1 + fi + done + + if [[ "$SETUP_MODE" == "signing" ]]; then + for variable_name in \ + KEY_VAULT_NAME \ + KEY_NAME; do + if [[ -z "${!variable_name}" ]]; then + echo "$variable_name is required for AKV PKCS11 signing." >&2 + exit 1 + fi + done + fi + + - name: Resolve signing tool paths + id: paths + shell: bash + run: | + set -euo pipefail + + if [[ "${RUNNER_OS}" != "Linux" ]]; then + echo "Prebuilt AKV PKCS11 signing tools are only vendored for Linux runners, got ${RUNNER_OS}." >&2 + exit 1 + fi + + if [[ "${RUNNER_ARCH}" != "X64" && "${RUNNER_ARCH}" != "AMD64" ]]; then + echo "Prebuilt AKV PKCS11 signing tools are only vendored for amd64 runners, got ${RUNNER_ARCH}." >&2 + exit 1 + fi + + provider_root="${RUNNER_TEMP}/akv-pkcs11-provider" + rcodesign_root="${RUNNER_TEMP}/rcodesign-root" + signing_certificate_pem="${RUNNER_TEMP}/akv-signing-cert.pem" + library_name="libakv_pkcs_11.so" + + mkdir -p "$provider_root" "$rcodesign_root/bin" + + { + echo "pkcs11_library=$provider_root/$library_name" + echo "pkcs11_manifest=$provider_root/akv-pkcs11-provider.manifest" + echo "rcodesign_root=$rcodesign_root" + echo "rcodesign=$rcodesign_root/bin/rcodesign" + echo "signing_certificate_pem=$signing_certificate_pem" + } >> "$GITHUB_OUTPUT" + + - name: Validate Azure credentials for private signing artifacts + shell: bash + env: + AZURE_CLIENT_ID: ${{ inputs.azure-client-id }} + AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }} + AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }} + run: | + set -euo pipefail + + for variable_name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID; do + if [[ -z "${!variable_name}" ]]; then + echo "$variable_name is required for private AKV PKCS11 signing artifacts." >&2 + exit 1 + fi + done + + - name: Log in to Azure with GitHub OIDC + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ inputs.azure-client-id }} + tenant-id: ${{ inputs.azure-tenant-id }} + subscription-id: ${{ inputs.azure-subscription-id }} + + - name: Install prebuilt signing tools + shell: bash + env: + RCODESIGN_BLOB_URI: ${{ inputs.rcodesign-blob-uri }} + RCODESIGN_SHA256: ${{ inputs.rcodesign-sha256 }} + RCODESIGN: ${{ steps.paths.outputs.rcodesign }} + AKV_PKCS11_LIBRARY_BLOB_URI: ${{ inputs.akv-pkcs11-library-blob-uri }} + AKV_PKCS11_LIBRARY_SHA256: ${{ inputs.akv-pkcs11-library-sha256 }} + PKCS11_LIBRARY: ${{ steps.paths.outputs.pkcs11_library }} + PKCS11_MANIFEST: ${{ steps.paths.outputs.pkcs11_manifest }} + run: | + set -euo pipefail + + download_az_blob_uri() { + local uri="$1" + local destination="$2" + local rest account container blob + + rest="${uri#az://}" + account="${rest%%/*}" + rest="${rest#*/}" + container="${rest%%/*}" + blob="${rest#*/}" + + if [[ -z "$account" || -z "$container" || -z "$blob" || "$blob" == "$rest" ]]; then + echo "Invalid Azure Blob URI. Expected az:////." >&2 + exit 1 + fi + + mkdir -p "$(dirname "$destination")" + rm -f "$destination" + if ! az storage blob download \ + --account-name "$account" \ + --container-name "$container" \ + --name "$blob" \ + --file "$destination" \ + --auth-mode login \ + --only-show-errors \ + >/dev/null 2>&1; then + echo "Failed to download a private signing artifact from Azure Blob Storage." >&2 + exit 1 + fi + } + + verify_sha256() { + local path="$1" + local expected="$2" + local actual + + actual="$(shasum -a 256 "$path" | awk '{ print $1 }')" + if [[ "$actual" != "$expected" ]]; then + echo "SHA-256 verification failed for '$path'." >&2 + exit 1 + fi + } + + echo "Downloading prebuilt rcodesign." + download_az_blob_uri "$RCODESIGN_BLOB_URI" "$RCODESIGN" + verify_sha256 "$RCODESIGN" "$RCODESIGN_SHA256" + chmod 0755 "$RCODESIGN" + + echo "Downloading prebuilt AKV PKCS11 provider." + download_az_blob_uri "$AKV_PKCS11_LIBRARY_BLOB_URI" "$PKCS11_LIBRARY" + verify_sha256 "$PKCS11_LIBRARY" "$AKV_PKCS11_LIBRARY_SHA256" + chmod 0644 "$PKCS11_LIBRARY" + + { + echo "runner_os=$RUNNER_OS" + echo "runner_arch=$RUNNER_ARCH" + echo "library_name=$(basename "$PKCS11_LIBRARY")" + } > "$PKCS11_MANIFEST" + + - name: Verify downloaded signing tools + shell: bash + env: + RCODESIGN: ${{ steps.paths.outputs.rcodesign }} + RCODESIGN_SHA256: ${{ inputs.rcodesign-sha256 }} + PKCS11_LIBRARY: ${{ steps.paths.outputs.pkcs11_library }} + AKV_PKCS11_LIBRARY_SHA256: ${{ inputs.akv-pkcs11-library-sha256 }} + PKCS11_MANIFEST: ${{ steps.paths.outputs.pkcs11_manifest }} + run: | + set -euo pipefail + + verify_sha256() { + local path="$1" + local expected="$2" + local actual + + actual="$(shasum -a 256 "$path" | awk '{ print $1 }')" + if [[ "$actual" != "$expected" ]]; then + echo "SHA-256 verification failed for '$path'." >&2 + exit 1 + fi + } + + if [[ ! -x "$RCODESIGN" ]]; then + echo "rcodesign is missing or not executable at '$RCODESIGN'." >&2 + exit 1 + fi + + if [[ ! -f "$PKCS11_LIBRARY" ]]; then + echo "AKV PKCS11 provider library is missing at '$PKCS11_LIBRARY'." >&2 + exit 1 + fi + + verify_sha256 "$RCODESIGN" "$RCODESIGN_SHA256" + verify_sha256 "$PKCS11_LIBRARY" "$AKV_PKCS11_LIBRARY_SHA256" + + "$RCODESIGN" --version + "$RCODESIGN" notarize --help > /dev/null + + if [[ -f "$PKCS11_MANIFEST" ]]; then + echo "AKV PKCS11 provider artifact manifest is present." + else + echo "AKV PKCS11 provider artifact manifest is absent." >&2 + exit 1 + fi + + - name: Download signing certificate from Key Vault + if: ${{ inputs.setup-mode == 'signing' }} + shell: bash + env: + KEY_VAULT_NAME: ${{ inputs.key-vault-name }} + KEY_NAME: ${{ inputs.key-name }} + KEY_VERSION: ${{ inputs.key-version }} + CERTIFICATE_SHA256: ${{ inputs.certificate-sha256 }} + SIGNING_CERTIFICATE_PEM: ${{ steps.paths.outputs.signing_certificate_pem }} + run: | + set -euo pipefail + + certificate_version_args=() + if [[ -n "$KEY_VERSION" ]]; then + certificate_version_args+=(--version "$KEY_VERSION") + fi + + if ! az keyvault certificate download \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$KEY_NAME" \ + "${certificate_version_args[@]}" \ + --file "$SIGNING_CERTIFICATE_PEM" \ + --encoding PEM \ + --only-show-errors \ + >/dev/null 2>&1; then + echo "Failed to download the public signing certificate from Azure Key Vault." >&2 + exit 1 + fi + + if [[ -n "$CERTIFICATE_SHA256" ]]; then + actual_sha256="$( + openssl x509 -in "$SIGNING_CERTIFICATE_PEM" -noout -fingerprint -sha256 | + awk -F= '{ print toupper($2) }' | + tr -d ':\r\n' + )" + expected_sha256="$(printf '%s' "$CERTIFICATE_SHA256" | tr '[:lower:]' '[:upper:]' | tr -d ':\r\n ')" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + echo "Downloaded signing certificate SHA-256 did not match the expected fingerprint." >&2 + exit 1 + fi + fi + + - name: Export AKV PKCS11 signing environment + if: ${{ inputs.setup-mode == 'signing' }} + shell: bash + env: + RCODESIGN_ROOT: ${{ steps.paths.outputs.rcodesign_root }} + PKCS11_LIBRARY: ${{ steps.paths.outputs.pkcs11_library }} + SIGNING_CERTIFICATE_PEM: ${{ steps.paths.outputs.signing_certificate_pem }} + KEY_VAULT_NAME: ${{ inputs.key-vault-name }} + KEY_NAME: ${{ inputs.key-name }} + KEY_VERSION: ${{ inputs.key-version }} + run: | + set -euo pipefail + + { + echo "$RCODESIGN_ROOT/bin" + } >> "$GITHUB_PATH" + + { + echo "OAI_CODESIGN_BACKEND=akv-pkcs11" + echo "OAI_AKV_PKCS11_LIBRARY=$PKCS11_LIBRARY" + echo "OAI_AKV_SIGNING_CERTIFICATE_PEM=$SIGNING_CERTIFICATE_PEM" + echo "OAI_AKV_KEY_LABEL=$KEY_NAME" + echo "AZURE_CREDENTIAL_KIND=azurecli" + echo "AZURE_KEYVAULT_NAME=$KEY_VAULT_NAME" + if [[ -n "$KEY_VERSION" ]]; then + echo "AZURE_KEYVAULT_KEY_VERSION=$KEY_VERSION" + fi + } >> "$GITHUB_ENV" diff --git a/.github/actions/macos-code-sign/codex.entitlements.plist b/.github/scripts/macos-signing/codex.entitlements.plist similarity index 100% rename from .github/actions/macos-code-sign/codex.entitlements.plist rename to .github/scripts/macos-signing/codex.entitlements.plist diff --git a/.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh b/.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh new file mode 100755 index 0000000000..8ebe490d41 --- /dev/null +++ b/.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +# Submits a signed standalone macOS binary to Apple notarization through +# rcodesign. Standalone binaries cannot carry a stapled ticket, so the binary +# is submitted in a ZIP and the successful notarization log is retained. + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: notarize_macos_binary_with_rcodesign.sh --binary PATH [--report-dir PATH] [--max-wait-seconds SECONDS] + +Options: + --binary PATH Signed standalone macOS binary to notarize. + --report-dir PATH Directory for notarization logs. + --max-wait-seconds SECONDS Maximum rcodesign notarization wait time. +EOF +} + +binary_path="" +report_dir="${RUNNER_TEMP:-/tmp}/macos-binary-notarization-verification" +max_wait_seconds="600" + +while [[ $# -gt 0 ]]; do + case "$1" in + --binary) + binary_path="${2:-}" + shift 2 + ;; + --report-dir) + report_dir="${2:-}" + shift 2 + ;; + --max-wait-seconds) + max_wait_seconds="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown notarization argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$binary_path" ]]; then + echo "--binary is required." >&2 + usage + exit 2 +fi + +if [[ ! -f "$binary_path" ]]; then + echo "Binary does not exist: $binary_path" >&2 + exit 1 +fi + +if [[ ! "$max_wait_seconds" =~ ^[0-9]+$ ]]; then + echo "--max-wait-seconds must be a non-negative integer." >&2 + exit 2 +fi + +for command_name in rcodesign zip; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "$command_name was not found on PATH." >&2 + exit 1 + fi +done + +missing_environment=0 +for variable_name in \ + APPLE_NOTARIZATION_ISSUER_ID \ + APPLE_NOTARIZATION_KEY_ID \ + APPLE_NOTARIZATION_KEY_P8 +do + if [[ -z "${!variable_name:-}" ]]; then + echo "$variable_name must be set from CI secrets before notarizing a binary." >&2 + missing_environment=1 + fi +done + +if [[ "$missing_environment" -ne 0 ]]; then + exit 2 +fi + +mkdir -p "$report_dir" + +notarization_temp_dir="$(mktemp -d)" +trap 'rm -rf "$notarization_temp_dir" >/dev/null' EXIT + +private_key_path="$notarization_temp_dir/AuthKey_${APPLE_NOTARIZATION_KEY_ID}.p8" +if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 --decode >"$private_key_path" 2>/dev/null; then + if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -D >"$private_key_path" 2>/dev/null; then + echo "APPLE_NOTARIZATION_KEY_P8 must be a base64-encoded .p8 private key." >&2 + exit 2 + fi +fi +chmod 600 "$private_key_path" + +api_key_path="$notarization_temp_dir/app-store-connect-api-key.json" +rcodesign encode-app-store-connect-api-key \ + --output-path "$api_key_path" \ + "$APPLE_NOTARIZATION_ISSUER_ID" \ + "$APPLE_NOTARIZATION_KEY_ID" \ + "$private_key_path" \ + >"$report_dir/encode-app-store-connect-api-key.log" 2>&1 + +binary_name="$(basename "$binary_path")" +archive_path="$notarization_temp_dir/${binary_name}.zip" +( + cd "$(dirname "$binary_path")" + zip -q "$archive_path" "$binary_name" +) + +notarization_log="$report_dir/${binary_name}-notarization.log" +rcodesign notarize \ + --api-key-file "$api_key_path" \ + --max-wait-seconds "$max_wait_seconds" \ + --wait \ + "$archive_path" \ + 2>&1 | tee "$notarization_log" + +{ + echo "binary_name=$binary_name" + echo "max_wait_seconds=$max_wait_seconds" + echo "binary_sha256=$(shasum -a 256 "$binary_path" | awk '{ print $1 }')" + echo "rcodesign_notarize=completed" +} >"$report_dir/${binary_name}-notarization-summary.txt" diff --git a/.github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh b/.github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh new file mode 100755 index 0000000000..a1125d436a --- /dev/null +++ b/.github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +# Notarizes and staples a signed macOS DMG through rcodesign. +# +# This is the Linux-compatible notarization path for the AKV/PKCS#11 signing +# flow. It records notarization inputs and logs so workflow artifacts can be +# audited without exposing the App Store Connect private key. + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: notarize_macos_dmg_with_rcodesign.sh --dmg PATH [--report-dir PATH] [--max-wait-seconds SECONDS] + +Options: + --dmg PATH Signed DMG to submit to Apple notarization. + --report-dir PATH Directory for notarization logs. + --max-wait-seconds SECONDS Maximum rcodesign notarization wait time. +EOF +} + +dmg_path="" +report_dir="${RUNNER_TEMP:-/tmp}/macos-notarization-verification" +max_wait_seconds="600" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) + dmg_path="${2:-}" + shift 2 + ;; + --report-dir) + report_dir="${2:-}" + shift 2 + ;; + --max-wait-seconds) + max_wait_seconds="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown notarization argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$dmg_path" ]]; then + echo "--dmg is required." >&2 + usage + exit 2 +fi + +if [[ ! -f "$dmg_path" ]]; then + echo "DMG does not exist: $dmg_path" >&2 + exit 1 +fi + +if [[ ! "$max_wait_seconds" =~ ^[0-9]+$ ]]; then + echo "--max-wait-seconds must be a non-negative integer." >&2 + exit 2 +fi + +if ! command -v rcodesign > /dev/null 2>&1; then + echo "rcodesign was not found on PATH." >&2 + exit 1 +fi + +missing_environment=0 +for variable_name in \ + APPLE_NOTARIZATION_ISSUER_ID \ + APPLE_NOTARIZATION_KEY_ID \ + APPLE_NOTARIZATION_KEY_P8 +do + if [[ -z "${!variable_name:-}" ]]; then + echo "$variable_name must be set from CI secrets before notarizing a DMG." >&2 + missing_environment=1 + fi +done + +if [[ "$missing_environment" -ne 0 ]]; then + exit 2 +fi + +mkdir -p "$report_dir" + +notarization_temp_dir="$(mktemp -d)" +trap 'rm -rf "$notarization_temp_dir" > /dev/null' EXIT + +private_key_path="$notarization_temp_dir/AuthKey_${APPLE_NOTARIZATION_KEY_ID}.p8" +if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 --decode > "$private_key_path" 2> /dev/null; then + if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -D > "$private_key_path" 2> /dev/null; then + echo "APPLE_NOTARIZATION_KEY_P8 must be a base64-encoded .p8 private key." >&2 + exit 2 + fi +fi +chmod 600 "$private_key_path" + +api_key_path="$notarization_temp_dir/app-store-connect-api-key.json" +rcodesign encode-app-store-connect-api-key \ + --output-path "$api_key_path" \ + "$APPLE_NOTARIZATION_ISSUER_ID" \ + "$APPLE_NOTARIZATION_KEY_ID" \ + "$private_key_path" \ + > "$report_dir/encode-app-store-connect-api-key.log" 2>&1 + +notarization_log="$report_dir/dmg-notarization.log" +rcodesign notarize \ + --api-key-file "$api_key_path" \ + --max-wait-seconds "$max_wait_seconds" \ + --staple \ + "$dmg_path" \ + 2>&1 | tee "$notarization_log" + +{ + echo "dmg_path=$dmg_path" + echo "max_wait_seconds=$max_wait_seconds" + echo "dmg_sha256=$(shasum -a 256 "$dmg_path" | awk '{ print $1 }')" + echo "rcodesign_notarize_staple=completed" +} > "$report_dir/dmg-notarization-summary.txt" diff --git a/.github/scripts/macos-signing/sign_macos_code.sh b/.github/scripts/macos-signing/sign_macos_code.sh new file mode 100755 index 0000000000..9f86741410 --- /dev/null +++ b/.github/scripts/macos-signing/sign_macos_code.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash + +# Small compatibility wrapper around native codesign and rcodesign. +# +# Existing packaging scripts call this instead of choosing a signing backend +# directly. OAI_CODESIGN_BACKEND=akv-pkcs11 routes signing through rcodesign +# while preserving the option, entitlement, identifier, timestamp, and deep +# signing surface used by the native codesign path. + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: sign_macos_code.sh --target PATH --identity IDENTITY [options] + +Options: + --deep true|false + --entitlements PATH + --identifier IDENTIFIER + --identity IDENTITY + --options FLAGS + --target PATH + --timestamp true|false|none +EOF +} + +target="" +identity="" +options="" +entitlements_file="" +identifier="" +deep="false" +timestamp="true" + +while [[ $# -gt 0 ]]; do + case "$1" in + --deep) + deep="${2:-}" + shift 2 + ;; + --entitlements) + entitlements_file="${2:-}" + shift 2 + ;; + --identifier) + identifier="${2:-}" + shift 2 + ;; + --identity) + identity="${2:-}" + shift 2 + ;; + --options) + options="${2:-}" + shift 2 + ;; + --target) + target="${2:-}" + shift 2 + ;; + --timestamp) + timestamp="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown signing argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$target" ]]; then + echo "--target is required." >&2 + usage + exit 2 +fi + +if [[ ! -e "$target" ]]; then + echo "Signing target does not exist: $target" >&2 + exit 1 +fi + +case "$deep" in + true|false) ;; + *) + echo "--deep must be true or false, got '$deep'." >&2 + exit 2 + ;; +esac + +case "$timestamp" in + true|false|none) ;; + *) + echo "--timestamp must be true, false, or none, got '$timestamp'." >&2 + exit 2 + ;; +esac + +sign_with_codesign() { + if [[ -z "$identity" ]]; then + echo "Native codesign requires --identity." >&2 + exit 2 + fi + + local -a args + args=(--force) + + if [[ "$deep" == "true" ]]; then + args+=(--deep) + fi + + if [[ -n "$options" ]]; then + args+=(--options "$options") + fi + + case "$timestamp" in + true) + args+=(--timestamp) + ;; + false|none) + args+=(--timestamp=none) + ;; + esac + + if [[ -n "$entitlements_file" ]]; then + args+=(--entitlements "$entitlements_file") + fi + + if [[ -n "$identifier" ]]; then + args+=(--identifier "$identifier") + fi + + args+=(--sign "$identity" "$target") + codesign "${args[@]}" +} + +append_rcodesign_flags() { + local raw_options="$1" + local option="" + + if [[ -z "$raw_options" ]]; then + return 0 + fi + + IFS=',' read -ra split_options <<< "$raw_options" + for option in "${split_options[@]}"; do + option="${option//[[:space:]]/}" + [[ -z "$option" ]] && continue + + case "$option" in + host|hard|kill|expires|restrict|library|runtime|linker-signed) + rcodesign_args+=(--code-signature-flags "$option") + ;; + *) + echo "Unsupported rcodesign code signature option: $option" >&2 + exit 2 + ;; + esac + done +} + +rcodesign_options_require_notarization() { + local raw_options="$1" + local option="" + + if [[ -z "$raw_options" || "$timestamp" != "true" ]]; then + return 1 + fi + + IFS=',' read -ra split_options <<< "$raw_options" + for option in "${split_options[@]}"; do + option="${option//[[:space:]]/}" + if [[ "$option" == "runtime" ]]; then + return 0 + fi + done + + return 1 +} + +sign_with_rcodesign() { + : "${OAI_AKV_PKCS11_LIBRARY:?OAI_AKV_PKCS11_LIBRARY is required for AKV PKCS11 signing.}" + : "${OAI_AKV_SIGNING_CERTIFICATE_PEM:?OAI_AKV_SIGNING_CERTIFICATE_PEM is required for AKV PKCS11 signing.}" + : "${OAI_AKV_KEY_LABEL:?OAI_AKV_KEY_LABEL is required for AKV PKCS11 signing.}" + + if ! command -v rcodesign >/dev/null 2>&1; then + echo "rcodesign was not found on PATH." >&2 + exit 1 + fi + + local -a rcodesign_args + rcodesign_args=( + sign + --config-file /dev/null + --pkcs11-library "$OAI_AKV_PKCS11_LIBRARY" + --pkcs11-certificate-file "$OAI_AKV_SIGNING_CERTIFICATE_PEM" + --pkcs11-key-label "$OAI_AKV_KEY_LABEL" + ) + + if [[ "$deep" == "false" ]]; then + rcodesign_args+=(--shallow) + fi + + case "$timestamp" in + true) + ;; + false|none) + rcodesign_args+=(--timestamp-url none) + ;; + esac + + append_rcodesign_flags "$options" + if rcodesign_options_require_notarization "$options"; then + rcodesign_args+=(--for-notarization) + fi + + if [[ -n "$entitlements_file" ]]; then + rcodesign_args+=(--entitlements-xml-file "$entitlements_file") + fi + + if [[ -n "$identifier" ]]; then + rcodesign_args+=(--binary-identifier "$identifier") + fi + + rcodesign_args+=("$target") + rcodesign "${rcodesign_args[@]}" +} + +case "${OAI_CODESIGN_BACKEND:-codesign}" in + codesign|"") + sign_with_codesign + ;; + akv-pkcs11) + sign_with_rcodesign + ;; + *) + echo "Unsupported OAI_CODESIGN_BACKEND: ${OAI_CODESIGN_BACKEND}" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 55f95fa270..5e3b263ed0 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -5,6 +5,9 @@ # git push origin rust-v0.1.0 # ``` # +# Tag releases sign macOS binaries and DMGs through the protected `codesigning` +# GitHub environment and Azure Key Vault before final verification on macOS. +# # To use external macOS signing, manually dispatch `release_mode=build_unsigned`, # sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff # archive as a GitHub Release asset, then manually dispatch @@ -113,18 +116,18 @@ jobs: echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." fi - # 1. Must be a tag and match the regex + # All release modes must run from a tag. [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ - || { echo "❌ Not a tag push"; exit 1; } + || { echo "❌ Not a tag ref"; exit 1; } + + # Release tags must match the version in Cargo.toml. [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } - # 2. Extract versions tag_ver="${GITHUB_REF_NAME#rust-v}" cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \ | sed -E 's/version *= *"([^"]+)".*/\1/')" - # 3. Compare [[ "${tag_ver}" == "${cargo_ver}" ]] \ || { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; } @@ -154,7 +157,6 @@ jobs: # submodules through SecureTransport/libgit2, especially libwebrtc's # libyuv submodule from chromium.googlesource.com. CARGO_NET_GIT_FETCH_WITH_CLI: "true" - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} strategy: fail-fast: false @@ -333,7 +335,7 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} + - if: ${{ runner.os == 'macOS' }} name: Stage unsigned macOS artifacts shell: bash run: | @@ -358,7 +360,7 @@ jobs: zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} + - if: ${{ runner.os == 'macOS' }} name: Upload unsigned macOS artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -374,75 +376,8 @@ jobs: artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} - name: MacOS code signing (binaries) - uses: ./.github/actions/macos-code-sign - with: - target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} - sign-binaries: "true" - sign-dmg: "false" - apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} - apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} - name: Build macOS dmg - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dmg_root="${RUNNER_TEMP}/codex-dmg-root" - volname="Codex (${target})" - dmg_path="${release_dir}/codex-${target}.dmg" - - # The previous "MacOS code signing (binaries)" step signs + notarizes the - # built artifacts in `${release_dir}`. This step packages *those same* - # signed binaries into a dmg. - rm -rf "$dmg_root" - mkdir -p "$dmg_root" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done - - rm -f "$dmg_path" - hdiutil create \ - -volname "$volname" \ - -srcfolder "$dmg_root" \ - -format UDZO \ - -ov \ - "$dmg_path" - - if [[ ! -f "$dmg_path" ]]; then - echo "dmg $dmg_path not found after build" - exit 1 - fi - - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} - name: MacOS code signing (dmg) - uses: ./.github/actions/macos-code-sign - with: - target: ${{ matrix.target }} - sign-binaries: "false" - sign-dmg: "true" - apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} - apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} + if: ${{ runner.os != 'macOS' }} shell: bash run: | dest="dist/${{ matrix.target }}" @@ -472,7 +407,7 @@ jobs: fi - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} + if: ${{ runner.os != 'macOS' }} shell: bash env: TARGET: ${{ matrix.target }} @@ -486,7 +421,7 @@ jobs: --archive-dir "dist/${TARGET}" - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} + if: ${{ matrix.bundle == 'primary' && runner.os != 'macOS' }} shell: bash run: | set -euo pipefail @@ -529,7 +464,7 @@ jobs: "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} + if: ${{ matrix.bundle == 'primary' && runner.os != 'macOS' }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: python-runtime-wheel-${{ matrix.target }} @@ -537,7 +472,7 @@ jobs: if-no-files-found: error - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} + if: ${{ runner.os != 'macOS' }} shell: bash run: | # Path that contains the uncompressed binaries for the current @@ -574,7 +509,7 @@ jobs: done - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} + if: ${{ runner.os != 'macOS' }} with: name: ${{ matrix.artifact_name }} # Upload the per-binary .zst files, .tar.gz equivalents, and any @@ -582,6 +517,576 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* + sign-macos-binaries: + if: ${{ github.event_name != 'workflow_dispatch' }} + needs: build + name: Sign macOS binaries - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: codesigning + deployment: false + permissions: + contents: read + id-token: write + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download unsigned macOS binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact_name }}-unsigned + path: ${{ runner.temp }}/unsigned-macos + + - name: Set up AKV PKCS11 macOS signing + uses: ./.github/actions/setup-akv-pkcs11-codesigning + with: + rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }} + rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }} + akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }} + akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }} + azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }} + azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }} + key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }} + key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }} + key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }} + certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 || '' }} + + - name: Sign and notarize macOS binaries + shell: bash + env: + TARGET: ${{ matrix.target }} + BINARIES: ${{ matrix.binaries }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + + input_dir="${RUNNER_TEMP}/unsigned-macos" + output_dir="${GITHUB_WORKSPACE}/signed-macos/${TARGET}" + report_dir="${GITHUB_WORKSPACE}/macos-binary-signing-verification/${TARGET}" + mkdir -p "$output_dir" "$report_dir" + + for binary in ${BINARIES}; do + unsigned_path="${input_dir}/${binary}-${TARGET}-unsigned.zst" + signed_path="${output_dir}/${binary}" + if [[ ! -f "$unsigned_path" ]]; then + echo "Unsigned binary $unsigned_path not found" + exit 1 + fi + + zstd -d --stdout "$unsigned_path" >"$signed_path" + chmod 0755 "$signed_path" + + .github/scripts/macos-signing/sign_macos_code.sh \ + --target "$signed_path" \ + --identity unused \ + --deep false \ + --identifier "$binary" \ + --options runtime \ + --timestamp true \ + --entitlements .github/scripts/macos-signing/codex.entitlements.plist + + mkdir -p "${report_dir}/${binary}" + rcodesign print-signature-info "$signed_path" \ + >"${report_dir}/${binary}/signature-info.yaml" + + .github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh \ + --binary "$signed_path" \ + --report-dir "${report_dir}/${binary}" + done + + - name: Upload signed macOS binaries + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-signed-binaries + path: signed-macos/${{ matrix.target }}/* + if-no-files-found: error + + - name: Upload binary signing verification + if: ${{ always() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-binary-signing-verification + path: macos-binary-signing-verification/${{ matrix.target }}/ + if-no-files-found: warn + + package-macos: + if: ${{ github.event_name != 'workflow_dispatch' }} + needs: sign-macos-binaries + name: Package macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: macos-15-xlarge + timeout-minutes: 45 + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download signed macOS binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact_name }}-signed-binaries + path: codex-rs/target/${{ matrix.target }}/release + + - name: Verify signed macOS binaries + shell: bash + run: | + set -euo pipefail + for binary in ${{ matrix.binaries }}; do + binary_path="target/${{ matrix.target }}/release/${binary}" + chmod 0755 "$binary_path" + codesign --verify --strict --verbose=2 "$binary_path" + done + + - name: Build unsigned macOS DMG + if: ${{ matrix.build_dmg == 'true' }} + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + release_dir="target/${target}/release" + dmg_root="${RUNNER_TEMP}/codex-dmg-root-${target}" + volname="Codex (${target})" + dmg_path="${release_dir}/codex-${target}.dmg" + + rm -rf "$dmg_root" + mkdir -p "$dmg_root" + + for binary in ${{ matrix.binaries }}; do + binary_path="${release_dir}/${binary}" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" + exit 1 + fi + ditto "$binary_path" "${dmg_root}/${binary}" + done + + rm -f "$dmg_path" + hdiutil create \ + -volname "$volname" \ + -srcfolder "$dmg_root" \ + -format UDZO \ + -ov \ + "$dmg_path" + + if [[ ! -f "$dmg_path" ]]; then + echo "DMG $dmg_path not found after build" + exit 1 + fi + + - name: Upload unsigned macOS DMG + if: ${{ matrix.build_dmg == 'true' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-unsigned-dmg + path: codex-rs/target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg + if-no-files-found: error + + - name: Stage macOS artifacts + shell: bash + run: | + set -euo pipefail + dest="dist/${{ matrix.target }}" + mkdir -p "$dest" + + for binary in ${{ matrix.binaries }}; do + cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" + done + + - name: Build Codex package archive + shell: bash + env: + TARGET: ${{ matrix.target }} + BUNDLE: ${{ matrix.bundle }} + run: | + set -euo pipefail + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "$TARGET" \ + --bundle "$BUNDLE" \ + --entrypoint-dir "target/${TARGET}/release" \ + --archive-dir "dist/${TARGET}" + + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + python3 \ + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + + - name: Compress artifacts + shell: bash + run: | + set -euo pipefail + dest="dist/${{ matrix.target }}" + for f in "$dest"/*; do + base="$(basename "$f")" + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + continue + fi + + tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" + zstd -T0 -19 --rm "$dest/$base" + done + + - name: Upload packaged macOS artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-packaged + path: codex-rs/dist/${{ matrix.target }}/* + if-no-files-found: error + + sign-macos-dmg: + if: ${{ github.event_name != 'workflow_dispatch' }} + needs: package-macos + name: Sign macOS DMG - ${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: codesigning + deployment: false + permissions: + contents: read + id-token: write + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + artifact_name: aarch64-apple-darwin + - target: x86_64-apple-darwin + artifact_name: x86_64-apple-darwin + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download unsigned macOS DMG + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact_name }}-unsigned-dmg + path: ${{ runner.temp }}/unsigned-dmg + + - name: Set up AKV PKCS11 macOS signing + uses: ./.github/actions/setup-akv-pkcs11-codesigning + with: + rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }} + rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }} + akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }} + akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }} + azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }} + azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }} + key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }} + key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }} + key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }} + certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 || '' }} + + - name: Sign, notarize, and staple macOS DMG + shell: bash + env: + TARGET: ${{ matrix.target }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + + dmg_path="${RUNNER_TEMP}/unsigned-dmg/codex-${TARGET}.dmg" + report_dir="${GITHUB_WORKSPACE}/macos-dmg-signing-verification/${TARGET}" + if [[ ! -f "$dmg_path" ]]; then + echo "Unsigned DMG $dmg_path not found" + exit 1 + fi + + .github/scripts/macos-signing/sign_macos_code.sh \ + --target "$dmg_path" \ + --identity unused \ + --deep false \ + --timestamp true + + mkdir -p "$report_dir" + rcodesign print-signature-info "$dmg_path" \ + >"${report_dir}/signature-info-before-notarization.yaml" + + .github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh \ + --dmg "$dmg_path" \ + --report-dir "$report_dir" + + rcodesign print-signature-info "$dmg_path" \ + >"${report_dir}/signature-info.yaml" + + - name: Upload signed macOS DMG + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-signed-dmg + path: ${{ runner.temp }}/unsigned-dmg/codex-${{ matrix.target }}.dmg + if-no-files-found: error + + - name: Upload DMG signing verification + if: ${{ always() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-dmg-signing-verification + path: macos-dmg-signing-verification/${{ matrix.target }}/ + if-no-files-found: warn + + finalize-macos: + if: ${{ github.event_name != 'workflow_dispatch' }} + needs: + - package-macos + - sign-macos-dmg + name: Verify macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: macos-15-xlarge + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + verify_dmg: "true" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + verify_dmg: "false" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + verify_dmg: "true" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + verify_dmg: "false" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download packaged macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact_name }}-packaged + path: codex-rs/dist/${{ matrix.target }} + + - name: Download signed macOS binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact_name }}-signed-binaries + path: ${{ runner.temp }}/signed-binaries + + - name: Download signed macOS DMG + if: ${{ matrix.verify_dmg == 'true' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact_name }}-signed-dmg + path: ${{ runner.temp }}/signed-dmg + + - name: Verify signed macOS artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + packaged_dir="dist/${target}" + expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/codex.entitlements.plist" + + verify_signed_binary() { + local path="$1" + local actual_entitlements normalized_actual normalized_expected + + chmod 0755 "$path" + codesign --verify --strict --verbose=2 "$path" + + actual_entitlements="$(mktemp)" + normalized_actual="$(mktemp)" + normalized_expected="$(mktemp)" + codesign -d --entitlements :- "$path" >"$actual_entitlements" + plutil -convert xml1 -o "$normalized_actual" "$actual_entitlements" + plutil -convert xml1 -o "$normalized_expected" "$expected_entitlements" + diff -u "$normalized_expected" "$normalized_actual" + rm -f "$actual_entitlements" "$normalized_actual" "$normalized_expected" + } + + for binary in ${{ matrix.binaries }}; do + binary_path="${RUNNER_TEMP}/signed-binaries/${binary}" + verify_signed_binary "$binary_path" + + direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}" + rm -rf "$direct_archive_dir" + mkdir -p "$direct_archive_dir" + tar -xzf "${packaged_dir}/${binary}-${target}.tar.gz" -C "$direct_archive_dir" + verify_signed_binary "${direct_archive_dir}/${binary}-${target}" + + direct_zstd_path="${RUNNER_TEMP}/${binary}-${target}-from-zstd" + zstd -d --stdout "${packaged_dir}/${binary}-${target}.zst" >"$direct_zstd_path" + verify_signed_binary "$direct_zstd_path" + done + + case "${{ matrix.bundle }}" in + primary) + package_stem="codex-package" + package_entrypoint="codex" + ;; + app-server) + package_stem="codex-app-server-package" + package_entrypoint="codex-app-server" + ;; + *) + echo "Unexpected macOS bundle: ${{ matrix.bundle }}" + exit 1 + ;; + esac + + package_dir="${RUNNER_TEMP}/${package_stem}-${target}" + rm -rf "$package_dir" + mkdir -p "$package_dir" + tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir" + verify_signed_binary "${package_dir}/bin/${package_entrypoint}" + + if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then + exit 0 + fi + + dmg_path="${RUNNER_TEMP}/signed-dmg/codex-${target}.dmg" + mount_dir="${RUNNER_TEMP}/codex-dmg-mount-${target}" + if [[ ! -f "$dmg_path" ]]; then + echo "Signed DMG $dmg_path not found" + exit 1 + fi + + hdiutil verify "$dmg_path" + codesign --verify --strict --verbose=2 "$dmg_path" + xcrun stapler validate "$dmg_path" + + rm -rf "$mount_dir" + mkdir -p "$mount_dir" + hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir" + cleanup_mount() { + hdiutil detach "$mount_dir" >/dev/null + } + trap cleanup_mount EXIT + + for binary in ${{ matrix.binaries }}; do + verify_signed_binary "${mount_dir}/${binary}" + done + + cleanup_mount + trap - EXIT + cp "$dmg_path" "dist/${target}/codex-${target}.dmg" + + - name: Upload verified macOS artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }} + path: codex-rs/dist/${{ matrix.target }}/* + if-no-files-found: error + stage-signed-macos: if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} needs: tag-check @@ -822,7 +1327,7 @@ jobs: codex-rs/dist/${{ matrix.target }}/* build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: @@ -830,7 +1335,7 @@ jobs: secrets: inherit argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -838,7 +1343,7 @@ jobs: publish: true zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml @@ -847,6 +1352,7 @@ jobs: needs: - tag-check - build + - finalize-macos - stage-signed-macos - build-windows - argument-comment-lint-release-assets @@ -861,6 +1367,7 @@ jobs: inputs.release_mode == 'promote_signed' && needs.stage-signed-macos.result == 'success' && needs.build.result == 'skipped' && + needs.finalize-macos.result == 'skipped' && needs.build-windows.result == 'skipped' && needs.argument-comment-lint-release-assets.result == 'skipped' && needs.zsh-release-assets.result == 'skipped' @@ -868,6 +1375,17 @@ jobs: ( (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && needs.build.result == 'success' && + ( + ( + github.event_name == 'workflow_dispatch' && + inputs.release_mode == 'build_unsigned' && + needs.finalize-macos.result == 'skipped' + ) || + ( + github.event_name != 'workflow_dispatch' && + needs.finalize-macos.result == 'success' + ) + ) && needs.stage-signed-macos.result == 'skipped' && needs.build-windows.result == 'success' && needs.argument-comment-lint-release-assets.result == 'success' && @@ -1046,6 +1564,15 @@ jobs: - name: Delete entries from dist/ that should not go in the release run: | rm -rf dist/windows-binaries* + rm -rf dist/*-apple-darwin*-signed-binaries + rm -rf dist/*-apple-darwin*-packaged + rm -rf dist/*-apple-darwin*-unsigned-dmg + rm -rf dist/*-apple-darwin*-signed-dmg + rm -rf dist/*-apple-darwin*-binary-signing-verification + rm -rf dist/*-apple-darwin*-dmg-signing-verification + if [[ "${SIGN_MACOS}" == "true" ]]; then + rm -rf dist/*-apple-darwin*-unsigned + fi # cargo-timing.html appears under multiple target-specific directories. # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. From 16d02ec77c6337ccea02a8c909e05bf3d905f887 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 12:46:02 +0200 Subject: [PATCH 020/141] chore: calm down (#26367) Prompt update to address feedback --- codex-rs/core/src/config/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5b79672b81..2c3cd4d6c3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -199,6 +199,7 @@ All agents in the team, including the agents that you can assign tasks to, are e You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. Child agents can also spawn their own sub-agents. You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. +Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. You will receive messages in the analysis channel in the form: ``` @@ -215,6 +216,7 @@ You can spawn sub-agents to handle subtasks, and those sub-agents can spawn thei You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. Child agents can also spawn their own sub-agents. +Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. When you provide a response in the final channel, that content is immediately delivered back to your parent agent. From d297616d3e6a27865fb327e7b4ea3d548f7fdb45 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 14:44:45 +0200 Subject: [PATCH 021/141] Gate automatic idle turns in Plan mode (#26147) ## Why Goal idle continuation is extension-triggered model-visible work, so it should follow one core-owned rule for when automatic work may start. In particular, it should not jump ahead of queued user/client work, start while another task is active, or inject a continuation turn while the thread is in Plan mode. Keeping this policy in `try_start_turn_if_idle` avoids passing `collaboration_mode` or review-specific state through `ThreadLifecycleContributor::on_thread_idle`. Active `/review` is covered by the same active-task gate because Review turns are not steerable. ## What Changed - Teach `Session::try_start_turn_if_idle` to reject automatic idle turns in Plan mode, both before reserving an idle turn and after building the turn context. - Document `CodexThread::try_start_turn_if_idle` as the extension-facing gate for automatic idle work, including Plan-mode and active Review-task behavior. - Add focused coverage for Plan-mode rejection and active Review-task rejection without queuing synthetic input. ## Testing - `just test -p codex-core try_start_turn_if_idle` --- codex-rs/core/src/codex_thread.rs | 56 ++++++++++++++++++- codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/session/inject.rs | 52 ++++++++++++++--- codex-rs/core/src/session/tests.rs | 86 ++++++++++++++++++++++++++++- codex-rs/ext/goal/src/runtime.rs | 8 ++- 5 files changed, 192 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 5035796767..ac28a58ec3 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -74,6 +74,46 @@ pub struct ThreadConfigSnapshot { pub thread_source: Option, } +/// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic +/// idle turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TryStartTurnIfIdleRejectionReason { + /// User/client-triggered mailbox work is already queued and must take + /// priority over extension-initiated idle work. + PendingTriggerTurn, + /// The thread is in Plan mode, where automatic idle work must not start a + /// new model turn. + PlanMode, + /// Another turn or task is active, or the idle reservation was lost before + /// the automatic turn could start. + Busy, +} + +/// Rejection returned when an extension asks to start automatic idle work but +/// the thread is not eligible to run it. +#[derive(Debug)] +pub struct TryStartTurnIfIdleError { + reason: TryStartTurnIfIdleRejectionReason, + input: Vec, +} + +impl TryStartTurnIfIdleError { + pub(crate) fn new(reason: TryStartTurnIfIdleRejectionReason, input: Vec) -> Self { + Self { reason, input } + } + + /// Returns the stable reason the automatic idle turn was rejected. + pub fn reason(&self) -> TryStartTurnIfIdleRejectionReason { + self.reason + } + + /// Consumes the rejection and returns the original model-visible input + /// unchanged, so callers can retry, drop, or log it explicitly. + pub fn into_input(self) -> Vec { + self.input + } +} + impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( @@ -276,11 +316,23 @@ impl CodexThread { self.codex.session.inject_if_running(items).await } - /// Starts a regular turn with model-visible items only if the thread is idle. + /// Starts an automatic regular turn with model-visible items only when idle + /// work is allowed for this thread. + /// + /// This is the required entry point for extensions that want to launch + /// model-visible work from `ThreadLifecycleContributor::on_thread_idle`. + /// The call succeeds only if no user/client-triggered turn is queued, no + /// task is currently active, and the thread is not in Plan mode. Active + /// Review tasks are rejected by the active-task check because Review turns + /// are not steerable. + /// + /// On rejection, the returned error includes a stable reason and carries + /// the original `items` unchanged so the caller can decide whether to drop + /// them, retry later, or log why no automatic turn was started. pub async fn try_start_turn_if_idle( &self, items: Vec, - ) -> Result<(), Vec> { + ) -> Result<(), TryStartTurnIfIdleError> { self.codex.session.try_start_turn_if_idle(items).await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 31ac529556..d3031ad222 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -22,6 +22,8 @@ mod config_lock; pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; +pub use codex_thread::TryStartTurnIfIdleError; +pub use codex_thread::TryStartTurnIfIdleRejectionReason; pub use session::turn_context::TurnContext; mod agent; mod attestation; diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 64d4aaade1..3f73cd96d0 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -1,9 +1,12 @@ use super::input_queue::TurnInput; use super::session::Session; use super::turn_context::TurnContext; +use crate::codex_thread::TryStartTurnIfIdleError; +use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::state::ActiveTurn; use crate::state::TurnState; use crate::tasks::RegularTask; +use codex_protocol::config_types::ModeKind; use codex_protocol::models::ResponseItem; use std::sync::Arc; @@ -32,22 +35,40 @@ impl Session { } } - /// Starts a regular turn with the provided items only if the session is idle. + /// Starts a regular turn with the provided items only if automatic idle work + /// is allowed for the current session state. + /// + /// This is the shared gate for extension-initiated idle work. It refuses to + /// start a turn when user/client-triggered work is queued, any task is still + /// active, or the session is currently in Plan mode. Active Review tasks are + /// covered by the active-task check because Review turns are not steerable. pub(crate) async fn try_start_turn_if_idle( self: &Arc, input: Vec, - ) -> Result<(), Vec> { + ) -> Result<(), TryStartTurnIfIdleError> { if input.is_empty() { return Ok(()); } if self.input_queue.has_trigger_turn_mailbox_items().await { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); + } + if self.collaboration_mode().await.mode == ModeKind::Plan { + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PlanMode, + input, + )); } let turn_state = { let mut active_turn = self.active_turn.lock().await; if active_turn.is_some() { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::Busy, + input, + )); } let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); Arc::clone(&active_turn.turn_state) @@ -56,18 +77,32 @@ impl Session { if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; self.maybe_start_turn_for_pending_work().await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } let turn_context = self .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) .await; + if turn_context.collaboration_mode.mode == ModeKind::Plan { + self.clear_reserved_idle_turn(&turn_state).await; + self.maybe_start_turn_for_pending_work().await; + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PlanMode, + input, + )); + } self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) .await; if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; self.maybe_start_turn_for_pending_work().await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } let still_reserved = { let active_turn = self.active_turn.lock().await; @@ -77,7 +112,10 @@ impl Session { }; if !still_reserved { self.clear_reserved_idle_turn(&turn_state).await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::Busy, + input, + )); } self.input_queue diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4d4709e65d..1643574206 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1,5 +1,6 @@ use super::turn_context::TurnEnvironment; use super::*; +use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::test_config; @@ -8770,7 +8771,90 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() { .await .expect_err("active turn should reject idle-only input"); - assert_eq!(vec![item], err); + assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); + assert_eq!(vec![item], err.into_input()); + assert_eq!( + Vec::::new(), + sess.input_queue.get_pending_input(&sess.active_turn).await + ); + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let mut collaboration_mode = sess.collaboration_mode().await; + collaboration_mode.mode = ModeKind::Plan; + { + let mut state = sess.state.lock().await; + state.session_configuration.collaboration_mode = collaboration_mode; + } + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("plan mode should reject automatic idle input"); + + assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, err.reason()); + assert_eq!(vec![item], err.into_input()); + assert!(sess.active_turn.lock().await.is_none()); + assert_eq!( + Vec::::new(), + sess.input_queue.get_pending_input(&sess.active_turn).await + ); +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root(), + Vec::new(), + "pending trigger".to_string(), + /*trigger_turn*/ true, + )) + .await; + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("pending trigger-turn mail should reject automatic idle input"); + + assert_eq!( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + err.reason() + ); + assert_eq!(vec![item], err.into_input()); + assert!(sess.active_turn.lock().await.is_none()); + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Review, + listen_to_cancellation_token: true, + }, + ) + .await; + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("active review turn should reject automatic idle input"); + + assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); + assert_eq!(vec![item], err.into_input()); assert_eq!( Vec::::new(), sess.input_queue.get_pending_input(&sess.active_turn).await diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index de818dabec..b58d1244e6 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -307,8 +307,12 @@ impl GoalRuntimeHandle { return Ok(()); }; - if thread.try_start_turn_if_idle(vec![item]).await.is_err() { - tracing::debug!("skipping goal continuation because the thread is no longer idle"); + if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await { + let reason = err.reason(); + tracing::debug!( + ?reason, + "skipping goal continuation because automatic idle work was rejected" + ); } let current_turn_is_goal_active = self From d46a98d31a9c32949580e6e3ce65b85772f3470d Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 15:28:06 +0200 Subject: [PATCH 022/141] Bridge host-loaded skills into the skills extension (#26172) ## Why The skills extension needs to become the path that exposes local host skills without losing the behavior already owned by core skill loading. Host skill discovery is not just `$CODEX_HOME/skills`: it also includes config layers, bundled-skill settings, plugin roots, runtime extra roots, and the filesystem for the selected primary environment. Rather than making the extension reload host skills and risk drifting from that authoritative load, this PR bridges the already-loaded per-turn skills outcome into the extension. That lets the extension advertise host skills and inject explicit `$skill` prompts while preserving the same roots, disabled/hidden state, rendered paths, and environment-backed file reads that the legacy path uses. ## What Changed - Adds `HostLoadedSkills` in `core-skills` to wrap the turn's `SkillLoadOutcome` and read `SKILL.md` through the filesystem that loaded that skill. - Stores `HostLoadedSkills` in turn extension data for normal turns and review turns, so the skills extension can consume the loaded host catalog without reloading it. - Adds `HostSkillProvider` under `ext/skills/src/provider/host.rs`, mapping host-loaded skill metadata into the skills-extension catalog/read contract. - Registers the host provider by default from `codex_skills_extension::install()`. - Preserves host skill metadata such as dependencies, disabled state, hidden-from-prompt policy, and slash-normalized display paths. - Passes host-loaded skills through `SkillListQuery` and `SkillReadRequest` so explicit skill invocation reads only resources from the loaded host catalog. - Adds integration coverage for a real legacy `$CODEX_HOME/skills/.../SKILL.md` skill being listed and injected through the installed extension. ## Testing - Added `installed_extension_loads_host_skills_from_legacy_roots` in `ext/skills/tests/skills_extension.rs`. - `just test -p codex-skills-extension` --- codex-rs/core-skills/src/injection.rs | 30 ++++ codex-rs/core-skills/src/lib.rs | 1 + codex-rs/core-skills/src/model.rs | 32 +++- codex-rs/core/src/session/review.rs | 10 +- codex-rs/core/src/session/turn.rs | 15 +- codex-rs/core/src/session/turn_context.rs | 2 + .../ext/extension-api/src/contributors.rs | 1 - codex-rs/ext/skills/src/extension.rs | 29 +++- codex-rs/ext/skills/src/lib.rs | 1 + codex-rs/ext/skills/src/provider.rs | 13 +- codex-rs/ext/skills/src/provider/host.rs | 134 +++++++++++++++ codex-rs/ext/skills/tests/skills_extension.rs | 155 +++++++++++++++--- 12 files changed, 390 insertions(+), 33 deletions(-) create mode 100644 codex-rs/ext/skills/src/provider/host.rs diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index 45979fa5c4..a358ddc637 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -28,6 +28,32 @@ pub struct SkillInjection { pub contents: String, } +/// Host skill prompts that have already been injected by an extension for this +/// turn. +/// +/// Core uses this to keep the legacy skill-injection path from sending the same +/// host `SKILL.md` body again while the skills extension is being wired in. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InjectedHostSkillPrompts { + paths: HashSet, +} + +impl InjectedHostSkillPrompts { + pub fn insert_path(&mut self, path: impl Into) { + let path = path.into(); + self.paths.insert(normalize_host_skill_path(&path)); + self.paths.insert(path); + } + + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } + + pub fn contains_path(&self, path: &str) -> bool { + self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path)) + } +} + pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, @@ -85,6 +111,10 @@ pub async fn build_skill_injections( result } +fn normalize_host_skill_path(path: &str) -> String { + normalize_skill_path(path).replace('\\', "/") +} + fn emit_skill_injected_metric( otel: Option<&SessionTelemetry>, skill: &SkillMetadata, diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index e4a61697d1..0390302afd 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -15,6 +15,7 @@ pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; pub use manager::SkillsManager; pub use mention_counts::build_skill_name_counts; +pub use model::HostLoadedSkills; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index fc8e9f5917..a9cc24306a 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fmt; +use std::io; use std::sync::Arc; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -23,7 +25,7 @@ pub struct SkillMetadata { } impl SkillMetadata { - fn allow_implicit_invocation(&self) -> bool { + pub fn allows_implicit_invocation(&self) -> bool { self.policy .as_ref() .and_then(|policy| policy.allow_implicit_invocation) @@ -103,7 +105,7 @@ impl SkillLoadOutcome { } pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool { - self.is_skill_enabled(skill) && skill.allow_implicit_invocation() + self.is_skill_enabled(skill) && skill.allows_implicit_invocation() } pub fn allowed_skills_for_implicit_invocation(&self) -> Vec { @@ -129,6 +131,32 @@ impl SkillLoadOutcome { } } +/// Host-loaded skills for one turn, including the filesystem mapping needed to +/// read skill bodies through the environment that loaded them. +#[derive(Debug, Clone)] +pub struct HostLoadedSkills { + outcome: Arc, +} + +impl HostLoadedSkills { + pub fn new(outcome: Arc) -> Self { + Self { outcome } + } + + pub fn outcome(&self) -> &SkillLoadOutcome { + self.outcome.as_ref() + } + + pub async fn read_skill_text(&self, skill: &SkillMetadata) -> io::Result { + let fs = self + .outcome + .file_system_for_skill(skill) + .unwrap_or_else(|| Arc::clone(&LOCAL_FS)); + fs.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None) + .await + } +} + #[derive(Clone, Default)] pub(crate) struct SkillFileSystemsByPath { values: Arc>>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 5a26cce2e3..de864ebb08 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,4 +1,5 @@ use super::*; +use codex_core_skills::HostLoadedSkills; use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; @@ -100,6 +101,13 @@ pub(super) async fn spawn_review_thread( parent_turn_context.network.is_some(), )); + let extension_data = Arc::new(codex_extension_api::ExtensionData::new( + review_turn_id.clone(), + )); + extension_data.insert(HostLoadedSkills::new( + parent_turn_context.turn_skills.outcome.clone(), + )); + let review_turn_context = TurnContext { sub_id: review_turn_id.clone(), trace_id: current_span_trace_id(), @@ -143,7 +151,7 @@ pub(super) async fn spawn_review_thread( dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, - extension_data: Arc::new(codex_extension_api::ExtensionData::new(review_turn_id)), + extension_data, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 42d609561e..2765de76fb 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; @@ -535,6 +536,9 @@ async fn build_skills_and_plugins( ) .await; + let injected_host_skill_prompts = turn_context + .extension_data + .get::(); let SkillInjections { items: skill_injections, warnings: skill_warnings, @@ -591,7 +595,16 @@ async fn build_skills_and_plugins( .track_plugin_used(tracking.clone(), plugin); } - let mut injection_items = skill_items; + let mut injection_items: Vec = match injected_host_skill_prompts { + Some(injected_host_skill_prompts) => skill_injections + .iter() + .filter(|skill| !injected_host_skill_prompts.contains_path(&skill.path)) + .map(|skill| { + ContextualUserFragment::into(crate::context::SkillInstructions::from(skill)) + }) + .collect(), + None => skill_items, + }; injection_items.extend(plugin_items); injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a6fcfca787..fea422cb4b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -2,6 +2,7 @@ use super::*; use crate::SkillLoadOutcome; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; +use codex_core_skills::HostLoadedSkills; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::SessionId; @@ -526,6 +527,7 @@ impl Session { )); let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); + extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); TurnContext { sub_id, trace_id: current_span_trace_id(), diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 14ccb68909..8706e8ee7a 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -89,7 +89,6 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } -/// WARNING: DO NOT USE YET /// Extension contribution that can add turn-local model input. /// /// Implementations should resolve only the model-visible input they own and diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 85ff513acf..3961d7d85d 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use codex_core::config::Config; +use codex_core_skills::HostLoadedSkills; use codex_core_skills::SkillInstructions; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_core_skills::injection::SkillInjection; use codex_extension_api::ConfigContributor; use codex_extension_api::ContextualUserFragment; @@ -20,6 +22,7 @@ use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalogEntry; use crate::catalog::SkillReadResult; use crate::catalog::SkillSourceKind; +use crate::provider::HostSkillProvider; use crate::provider::SkillListQuery; use crate::provider::SkillReadRequest; use crate::render::available_skills_fragment; @@ -78,6 +81,7 @@ impl TurnInputContributor for SkillsExtension { }; let config = thread_state.config(); + let host_loaded_skills = turn_store.get::(); let query = SkillListQuery { turn_id: input.turn_id.clone(), executor_authorities: input @@ -90,6 +94,7 @@ impl TurnInputContributor for SkillsExtension { ) }) .collect(), + host: host_loaded_skills.clone(), include_host_skills: true, include_bundled_skills: config.bundled_skills_enabled, include_remote_skills: true, @@ -109,8 +114,12 @@ impl TurnInputContributor for SkillsExtension { let mut warnings = catalog.warnings.clone(); let mut main_prompts_injected = false; + let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { - match self.read_main_prompt(entry).await { + match self + .read_main_prompt(entry, host_loaded_skills.clone()) + .await + { Ok(read_result) => { let (contents, truncated) = truncate_main_prompt_contents(read_result.contents.as_str()); @@ -129,6 +138,9 @@ impl TurnInputContributor for SkillsExtension { }; fragments.push(Box::new(SkillInstructions::from(&injection))); main_prompts_injected = true; + if entry.authority.kind == SkillSourceKind::Host { + injected_host_skill_prompts.insert_path(entry.main_prompt.0.clone()); + } } Err(message) => { let warning = format!("Failed to load skill `{}`: {message}", entry.name); @@ -144,18 +156,26 @@ impl TurnInputContributor for SkillsExtension { warnings, main_prompts_injected, }); + if !injected_host_skill_prompts.is_empty() { + turn_store.insert(injected_host_skill_prompts); + } fragments } } impl SkillsExtension { - async fn read_main_prompt(&self, entry: &SkillCatalogEntry) -> Result { + async fn read_main_prompt( + &self, + entry: &SkillCatalogEntry, + host_loaded_skills: Option>, + ) -> Result { self.providers .read(SkillReadRequest { authority: entry.authority.clone(), package: entry.id.clone(), resource: entry.main_prompt.clone(), + host: host_loaded_skills, }) .await .map_err(|err| err.message) @@ -170,7 +190,10 @@ impl SkillsExtension { } pub fn install(registry: &mut ExtensionRegistryBuilder) { - install_with_providers(registry, SkillProviders::default()); + install_with_providers( + registry, + SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())), + ); } pub fn install_with_providers( diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs index b5e224d49b..3a28c5fbdf 100644 --- a/codex-rs/ext/skills/src/lib.rs +++ b/codex-rs/ext/skills/src/lib.rs @@ -8,5 +8,6 @@ mod state; pub use extension::install; pub use extension::install_with_providers; +pub use provider::HostSkillProvider; pub use sources::SkillProviderSource; pub use sources::SkillProviders; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index f9c1b41fea..a968774afd 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -1,5 +1,10 @@ use std::future::Future; use std::pin::Pin; +use std::sync::Arc; + +mod host; + +use codex_core_skills::HostLoadedSkills; use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalog; @@ -9,20 +14,24 @@ use crate::catalog::SkillReadResult; use crate::catalog::SkillResourceId; use crate::catalog::SkillSearchResult; -#[derive(Clone, Debug, PartialEq, Eq)] +pub use host::HostSkillProvider; + +#[derive(Clone, Debug)] pub struct SkillListQuery { pub turn_id: String, pub executor_authorities: Vec, + pub host: Option>, pub include_host_skills: bool, pub include_bundled_skills: bool, pub include_remote_skills: bool, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct SkillReadRequest { pub authority: SkillAuthority, pub package: SkillPackageId, pub resource: SkillResourceId, + pub host: Option>, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs new file mode 100644 index 0000000000..27293aa1d5 --- /dev/null +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -0,0 +1,134 @@ +use codex_core_skills::SkillLoadOutcome; +use codex_core_skills::SkillMetadata; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillCatalogEntry; +use crate::catalog::SkillPackageId; +use crate::catalog::SkillProviderError; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillResourceId; +use crate::catalog::SkillSearchResult; +use crate::catalog::SkillSourceKind; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillProviderFuture; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +const HOST_AUTHORITY_ID: &str = "host"; + +/// Host-owned skill provider backed by the already-loaded turn skills. +/// +/// The provider intentionally does not reload or cache host skills. Core owns +/// skill loading, including plugin roots, runtime extra roots, and the primary +/// environment filesystem. This adapter only maps that loaded outcome into the +/// skills-extension catalog/read contract. +#[derive(Clone, Default)] +pub struct HostSkillProvider; + +impl HostSkillProvider { + pub fn new() -> Self { + Self + } +} + +impl SkillProvider for HostSkillProvider { + fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { + Box::pin(async move { + let Some(host_loaded_skills) = query.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + + Ok(catalog_from_outcome(host_loaded_skills.outcome())) + }) + } + + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { + Box::pin(async move { + let Some(host_loaded_skills) = request.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| { + let skill_path = skill.path_to_skills_md.to_string_lossy(); + skill_path == request.resource.0.as_str() + || skill_path.replace('\\', "/") == request.resource.0 + }) else { + return Err(SkillProviderError::new(format!( + "host skill resource is not loaded: {}", + request.resource.0 + ))); + }; + + let contents = host_loaded_skills + .read_skill_text(skill) + .await + .map_err(|err| { + SkillProviderError::new(format!( + "failed to read host skill resource {}: {err}", + request.resource.0 + )) + })?; + + Ok(SkillReadResult { + resource: request.resource, + contents, + }) + }) + } + + fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> { + Box::pin(async { Ok(SkillSearchResult::default()) }) + } +} + +fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog { + let mut catalog = SkillCatalog { + entries: Vec::new(), + warnings: outcome + .errors + .iter() + .map(|err| { + format!( + "Failed to load skill at {}: {}", + err.path.display(), + err.message + ) + }) + .collect(), + }; + + for (skill, enabled) in outcome.skills_with_enabled() { + catalog.push_entry(catalog_entry_from_skill(skill, enabled)); + } + + catalog +} + +fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalogEntry { + let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned(); + let display_path = skill_path.replace('\\', "/"); + let mut entry = SkillCatalogEntry::new( + SkillPackageId(skill_path.clone()), + SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID), + skill.name.clone(), + skill.description.clone(), + SkillResourceId(skill_path), + ) + .with_short_description(skill.short_description.clone()) + .with_display_path(display_path) + .with_dependencies(skill.dependencies.clone()); + + if !enabled { + entry = entry.disabled(); + } + if !skill.allows_implicit_invocation() { + entry = entry.hidden_from_prompt(); + } + + entry +} diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index acdb5587be..1e2a2f0b60 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -1,9 +1,15 @@ +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core_skills::HostLoadedSkills; +use codex_core_skills::SkillsLoadInput; +use codex_core_skills::SkillsManager; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadStartInput; @@ -21,6 +27,7 @@ use codex_skills_extension::catalog::SkillReadResult; use codex_skills_extension::catalog::SkillResourceId; use codex_skills_extension::catalog::SkillSearchResult; use codex_skills_extension::catalog::SkillSourceKind; +use codex_skills_extension::install; use codex_skills_extension::install_with_providers; use codex_skills_extension::provider::SkillListQuery; use codex_skills_extension::provider::SkillProvider; @@ -33,6 +40,93 @@ type TestResult = Result<(), Box>; static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); +#[tokio::test] +async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult { + let codex_home = test_codex_home(); + let skill_path = codex_home.join("skills").join("demo").join("SKILL.md"); + std::fs::create_dir_all( + skill_path + .parent() + .ok_or("skill path should have a parent")?, + )?; + std::fs::write( + &skill_path, + "---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n", + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .fallback_cwd(Some(codex_home.clone())) + .build() + .await?; + + let mut builder = ExtensionRegistryBuilder::new(); + install(&mut builder); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let manager = SkillsManager::new(config.codex_home.clone(), config.bundled_skills_enabled()); + let input = SkillsLoadInput::new( + config.cwd.clone(), + Vec::new(), + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ); + let loaded_skills = Arc::new(manager.skills_for_config(&input, /*fs*/ None).await); + let skill_path_string = loaded_skills + .skills + .iter() + .find(|skill| skill.name == "demo") + .ok_or("demo skill should load")? + .path_to_skills_md + .to_string_lossy() + .into_owned(); + let skill_prompt_path = skill_path_string.replace('\\', "/"); + let turn_store = ExtensionData::new("turn-1"); + turn_store.insert(HostLoadedSkills::new(Arc::clone(&loaded_skills))); + + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: vec![UserInput::Text { + text: "$demo".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &turn_store, + ) + .await; + + assert_eq!(2, fragments.len()); + assert!(fragments[0].render().contains("demo")); + assert!(fragments[0].render().contains(&skill_prompt_path)); + assert_eq!("user", fragments[1].role()); + assert!(fragments[1].render().contains("demo")); + assert!(fragments[1].render().contains("# Demo")); + assert!(fragments[1].render().contains(&skill_prompt_path)); + let injected_host_skill_prompts = turn_store + .get::() + .ok_or("host skill prompt marker should be set")?; + assert!(injected_host_skill_prompts.contains_path(&skill_path_string)); + + std::fs::remove_dir_all(codex_home)?; + Ok(()) +} + #[tokio::test] async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult { let host_read_requests = Arc::new(Mutex::new(Vec::new())); @@ -115,15 +209,12 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint() assert!(fragments[1].render().contains("lint-fix")); assert!(fragments[1].render().contains("# Lint Fix")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/lint-fix".to_string()), - resource: SkillResourceId("lint-fix/SKILL.md".to_string()), - }], - host_read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/lint-fix".to_string()), + SkillResourceId("lint-fix/SKILL.md".to_string()), + )], + read_request_keys(&host_read_requests) ); assert!( remote_read_requests @@ -220,15 +311,12 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult { assert!(!catalog_fragment.contains("hidden-skill")); assert!(fragments[1].render().contains("hidden-skill")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/hidden-skill".to_string()), - resource: SkillResourceId("hidden-skill/SKILL.md".to_string()), - }], - read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/hidden-skill".to_string()), + SkillResourceId("hidden-skill/SKILL.md".to_string()), + )], + read_request_keys(&read_requests) ); Ok(()) @@ -287,14 +375,35 @@ fn test_entry( } async fn default_config() -> std::io::Result { - let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); - let codex_home = std::env::temp_dir().join(format!( - "codex-skills-extension-test-{}-{id}", - std::process::id(), - )); + let codex_home = test_codex_home(); std::fs::create_dir_all(&codex_home)?; let config = Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), vec![]).await?; std::fs::remove_dir_all(codex_home)?; Ok(config) } + +fn test_codex_home() -> PathBuf { + let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codex-skills-extension-test-{}-{id}", + std::process::id(), + )) +} + +fn read_request_keys( + requests: &Arc>>, +) -> Vec<(SkillAuthority, SkillPackageId, SkillResourceId)> { + requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .map(|request| { + ( + request.authority.clone(), + request.package.clone(), + request.resource.clone(), + ) + }) + .collect() +} From 7da4af622ffc68d7b687e61ba8f19163af50fab8 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 4 Jun 2026 09:53:08 -0400 Subject: [PATCH 023/141] Optimize unbounded byte scans with memchr (#26265) ## Summary This PR adds `memchr` for some low-hanging performance improvements (namely, in MCP stdio, Ollama streaming, and full message-history newline counts). Codex produced the following release benchmarks: | Operation | Before | After | Speedup | | --- | ---: | ---: | ---: | | MCP 1 MiB chunked line | 2.172 s | 3.984 ms | 545x | | Ollama 1 MiB chunked line | 1.673 s | 2.790 ms | 600x | | Count newlines in 10 MiB history | 132.83 ms | 20.05 ms | 6.6x | With a "real" MCP setup (`ExecutorStdioServerLauncher` started a Python MCP server, completed `initialize`, requested `tools/list`, and deserialized a 1 MiB tool description over newline-delimited stdio), it's about 16x faster end-to-end: | Branch | 50 calls | Per call | | --- | ---: | ---: | | `main` | 862.53 ms | 17.25 ms | | this branch | 53.89 ms | 1.08 ms | `memchr` is already in our dependency tree and extremely widely used for this kind of optimized scanning. --- codex-rs/Cargo.lock | 3 + codex-rs/Cargo.toml | 1 + codex-rs/message-history/Cargo.toml | 1 + codex-rs/message-history/src/lib.rs | 6 +- codex-rs/message-history/src/tests.rs | 22 +++++ codex-rs/ollama/Cargo.toml | 1 + codex-rs/ollama/src/client.rs | 52 +++++++++++- codex-rs/ollama/src/lib.rs | 1 + codex-rs/ollama/src/line_buffer.rs | 32 ++++++++ codex-rs/ollama/src/line_buffer_tests.rs | 42 ++++++++++ codex-rs/rmcp-client/Cargo.toml | 1 + .../src/executor_process_transport.rs | 81 +++++++++++++------ .../src/executor_process_transport_tests.rs | 72 +++++++++++++++++ 13 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 codex-rs/ollama/src/line_buffer.rs create mode 100644 codex-rs/ollama/src/line_buffer_tests.rs create mode 100644 codex-rs/rmcp-client/src/executor_process_transport_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e9c171d778..f5e8b56230 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3293,6 +3293,7 @@ name = "codex-message-history" version = "0.0.0" dependencies = [ "codex-config", + "memchr", "pretty_assertions", "serde", "serde_json", @@ -3407,6 +3408,7 @@ dependencies = [ "codex-core", "codex-model-provider-info", "futures", + "memchr", "pretty_assertions", "reqwest 0.12.28", "semver", @@ -3576,6 +3578,7 @@ dependencies = [ "codex-utils-pty", "futures", "keyring", + "memchr", "oauth2", "pretty_assertions", "reqwest 0.13.4", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 59d161958d..15e0e2c010 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -318,6 +318,7 @@ libc = "0.2.182" log = "0.4" lru = "0.16.3" maplit = "1.0.2" +memchr = "2.7.6" mime_guess = "2.0.5" multimap = "0.10.0" notify = "8.2.0" diff --git a/codex-rs/message-history/Cargo.toml b/codex-rs/message-history/Cargo.toml index b67933d1d7..176b957909 100644 --- a/codex-rs/message-history/Cargo.toml +++ b/codex-rs/message-history/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] codex-config = { workspace = true } +memchr = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "rt"] } diff --git a/codex-rs/message-history/src/lib.rs b/codex-rs/message-history/src/lib.rs index 0d85cb8fe3..18a6d791ee 100644 --- a/codex-rs/message-history/src/lib.rs +++ b/codex-rs/message-history/src/lib.rs @@ -26,6 +26,7 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use memchr::memchr_iter; use serde::Deserialize; use serde::Serialize; @@ -43,6 +44,7 @@ use std::os::unix::fs::PermissionsExt; /// Filename that stores the message history inside `~/.codex`. const HISTORY_FILENAME: &str = "history.jsonl"; +const HISTORY_READ_BUFFER_SIZE: usize = 8192; /// When history exceeds the hard cap, trim it down to this fraction of `max_bytes`. const HISTORY_SOFT_CAP_RATIO: f64 = 0.8; @@ -330,13 +332,13 @@ async fn history_metadata_for_file(path: &Path) -> (u64, usize) { }; // Count newline bytes. - let mut buf = [0u8; 8192]; + let mut buf = [0u8; HISTORY_READ_BUFFER_SIZE]; let mut count = 0usize; loop { match file.read(&mut buf).await { Ok(0) => break, Ok(n) => { - count += buf[..n].iter().filter(|&&b| b == b'\n').count(); + count += memchr_iter(b'\n', &buf[..n]).count(); } Err(_) => return (log_id, 0), } diff --git a/codex-rs/message-history/src/tests.rs b/codex-rs/message-history/src/tests.rs index 88f0b7e007..f4c7e66e1c 100644 --- a/codex-rs/message-history/src/tests.rs +++ b/codex-rs/message-history/src/tests.rs @@ -41,6 +41,28 @@ async fn lookup_reads_history_entries() { assert_eq!(second_entry, entries[1]); } +#[tokio::test] +async fn history_metadata_counts_newlines_across_read_boundaries() { + let temp_dir = TempDir::new().expect("create temp dir"); + let history_path = temp_dir.path().join(HISTORY_FILENAME); + let mut contents = vec![b'x'; 3 * HISTORY_READ_BUFFER_SIZE + 1]; + let newline_offsets = [ + 0, + HISTORY_READ_BUFFER_SIZE - 1, + HISTORY_READ_BUFFER_SIZE, + 2 * HISTORY_READ_BUFFER_SIZE, + contents.len() - 2, + ]; + for offset in newline_offsets { + contents[offset] = b'\n'; + } + std::fs::write(&history_path, contents).expect("write history file"); + + let (_, count) = history_metadata_for_file(&history_path).await; + + assert_eq!(count, newline_offsets.len()); +} + #[tokio::test] async fn lookup_uses_stable_log_id_after_appends() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml index 5d30fbda28..42e32159f2 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -18,6 +18,7 @@ bytes = { workspace = true } codex-core = { workspace = true } codex-model-provider-info = { workspace = true } futures = { workspace = true } +memchr = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } semver = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 96601d0862..232c146836 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -1,4 +1,3 @@ -use bytes::BytesMut; use futures::StreamExt; use futures::stream::BoxStream; use semver::Version; @@ -6,6 +5,7 @@ use serde_json::Value as JsonValue; use std::collections::VecDeque; use std::io; +use crate::line_buffer::LineBuffer; use crate::parser::pull_events_from_value; use crate::pull::PullEvent; use crate::pull::PullProgressReporter; @@ -174,7 +174,7 @@ impl OllamaClient { } let mut stream = resp.bytes_stream(); - let mut buf = BytesMut::new(); + let mut buf = LineBuffer::default(); let _pending: VecDeque = VecDeque::new(); // Using an async stream adaptor backed by unfold-like manual loop. @@ -183,8 +183,7 @@ impl OllamaClient { match chunk { Ok(bytes) => { buf.extend_from_slice(&bytes); - while let Some(pos) = buf.iter().position(|b| *b == b'\n') { - let line = buf.split_to(pos + 1); + while let Some(line) = buf.take_line() { if let Ok(text) = std::str::from_utf8(&line) { let text = text.trim(); if text.is_empty() { continue; } @@ -263,6 +262,7 @@ impl OllamaClient { #[cfg(test)] mod tests { use super::*; + use assert_matches::assert_matches; use pretty_assertions::assert_eq; // Happy-path tests using a mock HTTP server; skip if sandbox network is disabled. @@ -333,6 +333,50 @@ mod tests { assert_eq!(version, Some(Version::new(0, 14, 1))); } + #[tokio::test] + async fn test_pull_model_stream_parses_large_json_lines() { + if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + tracing::info!( + "{} set; skipping test_pull_model_stream_parses_large_json_lines", + codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR + ); + return; + } + + let server = wiremock::MockServer::start().await; + let body = format!( + "{}\n{}\n", + serde_json::json!({ + "status": "pulling layers", + "padding": "x".repeat(128 * 1024), + }), + serde_json::json!({"status": "complete"}), + ); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/api/pull")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_raw(body, "application/x-ndjson"), + ) + .mount(&server) + .await; + + let client = OllamaClient::from_host_root(server.uri()); + let events = client + .pull_model_stream("test-model") + .await + .expect("start pull stream") + .collect::>() + .await; + + assert_matches!( + events.as_slice(), + [ + PullEvent::Status(pulling), + PullEvent::Status(complete), + ] if pulling == "pulling layers" && complete == "complete" + ); + } + #[tokio::test] async fn test_probe_server_happy_path_openai_compat_and_native() { if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 75800845b4..d0de830bcc 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -1,4 +1,5 @@ mod client; +mod line_buffer; mod parser; mod pull; mod url; diff --git a/codex-rs/ollama/src/line_buffer.rs b/codex-rs/ollama/src/line_buffer.rs new file mode 100644 index 0000000000..fb3d4b98f9 --- /dev/null +++ b/codex-rs/ollama/src/line_buffer.rs @@ -0,0 +1,32 @@ +use bytes::BytesMut; +use memchr::memchr; + +#[derive(Default)] +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +pub(crate) struct LineBuffer { + bytes: BytesMut, + /// Prefix already scanned and known not to contain a newline. + scanned_len: usize, +} + +impl LineBuffer { + pub(crate) fn extend_from_slice(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + pub(crate) fn take_line(&mut self) -> Option { + let Some(relative_index) = memchr(b'\n', &self.bytes[self.scanned_len..]) else { + self.scanned_len = self.bytes.len(); + return None; + }; + + let newline_index = self.scanned_len + relative_index; + let line = self.bytes.split_to(newline_index + 1); + self.scanned_len = 0; + Some(line) + } +} + +#[cfg(test)] +#[path = "line_buffer_tests.rs"] +mod tests; diff --git a/codex-rs/ollama/src/line_buffer_tests.rs b/codex-rs/ollama/src/line_buffer_tests.rs new file mode 100644 index 0000000000..58de07e864 --- /dev/null +++ b/codex-rs/ollama/src/line_buffer_tests.rs @@ -0,0 +1,42 @@ +use bytes::BytesMut; +use pretty_assertions::assert_eq; + +use super::LineBuffer; + +#[test] +fn searches_only_new_bytes_after_partial_line() { + let mut buffer = LineBuffer::default(); + + buffer.extend_from_slice(b"partial"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial"[..]), + scanned_len: 7, + } + ); + + buffer.extend_from_slice(b" line"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial line"[..]), + scanned_len: 12, + } + ); + + buffer.extend_from_slice(b"\nnext"); + assert_eq!( + buffer.take_line(), + Some(BytesMut::from(&b"partial line\n"[..])) + ); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"next"[..]), + scanned_len: 0, + } + ); +} diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 63c6d74e4a..e3417de70e 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -26,6 +26,7 @@ codex-utils-home-dir = { workspace = true } bytes = { workspace = true } futures = { workspace = true, default-features = false, features = ["std"] } keyring = { workspace = true, features = ["crypto-rust"] } +memchr = { workspace = true } oauth2 = "5" reqwest = { version = "0.13", default-features = false, features = [ "json", diff --git a/codex-rs/rmcp-client/src/executor_process_transport.rs b/codex-rs/rmcp-client/src/executor_process_transport.rs index 41f0b7660d..cff4f517ed 100644 --- a/codex-rs/rmcp-client/src/executor_process_transport.rs +++ b/codex-rs/rmcp-client/src/executor_process_transport.rs @@ -20,11 +20,11 @@ use std::future::Future; use std::io; -use std::mem::take; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; +use bytes::BytesMut; use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEvent; @@ -32,6 +32,7 @@ use codex_exec_server::ExecProcessEventReceiver; use codex_exec_server::ProcessId; use codex_exec_server::ProcessOutputChunk; use codex_exec_server::WriteStatus; +use memchr::memchr; use rmcp::service::RoleClient; use rmcp::service::RxJsonRpcMessage; use rmcp::service::TxJsonRpcMessage; @@ -46,6 +47,42 @@ use tracing::warn; static PROCESS_COUNTER: AtomicUsize = AtomicUsize::new(1); +#[derive(Default)] +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +struct LineBuffer { + bytes: BytesMut, + /// Prefix already scanned and known not to contain a newline. + scanned_len: usize, +} + +impl LineBuffer { + fn extend_from_slice(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + fn take_line(&mut self) -> Option { + let Some(relative_index) = memchr(b'\n', &self.bytes[self.scanned_len..]) else { + self.scanned_len = self.bytes.len(); + return None; + }; + + let newline_index = self.scanned_len + relative_index; + let mut line = self.bytes.split_to(newline_index + 1); + line.truncate(newline_index); + self.scanned_len = 0; + Some(line) + } + + fn take_remaining(&mut self) -> Option { + if self.bytes.is_empty() { + return None; + } + + self.scanned_len = 0; + Some(self.bytes.split()) + } +} + // Remote public implementation. /// A client-side rmcp transport backed by an executor-managed process. @@ -73,10 +110,10 @@ pub(super) struct ExecutorProcessTransport { /// Buffered child stdout bytes that have not yet formed a complete /// newline-delimited JSON-RPC message. - stdout: Vec, + stdout: LineBuffer, /// Buffered stderr bytes for diagnostic logging. - stderr: Vec, + stderr: LineBuffer, /// Whether the executor has reported process closure or a terminal /// subscription failure. Once closed, any remaining partial stdout line is @@ -105,8 +142,8 @@ impl ExecutorProcessTransport { process, events, program_name, - stdout: Vec::new(), - stderr: Vec::new(), + stdout: LineBuffer::default(), + stderr: LineBuffer::default(), closed: false, terminated: false, last_seq: 0, @@ -288,15 +325,10 @@ impl ExecutorProcessTransport { // so EOF after a complete JSON object behaves like local rmcp's // `decode_eof` handling. loop { - let line_end = self.stdout.iter().position(|byte| *byte == b'\n'); - let line = match (line_end, allow_partial && !self.stdout.is_empty()) { - (Some(index), _) => { - let mut line = self.stdout.drain(..=index).collect::>(); - line.pop(); - line - } - (None, true) => self.stdout.drain(..).collect(), - (None, false) => return None, + let line = match self.stdout.take_line() { + Some(line) => line, + None if allow_partial => self.stdout.take_remaining()?, + None => return None, }; let line = Self::trim_trailing_carriage_return(line); match from_slice::>(&line) { @@ -315,12 +347,8 @@ impl ExecutorProcessTransport { // Keep stderr line-oriented in logs so a chatty MCP server does not // produce one log record per byte chunk. self.stderr.extend_from_slice(bytes); - while let Some(index) = self.stderr.iter().position(|byte| *byte == b'\n') { - let mut line = self.stderr.drain(..=index).collect::>(); - line.pop(); - if line.last() == Some(&b'\r') { - line.pop(); - } + while let Some(line) = self.stderr.take_line() { + let line = Self::trim_trailing_carriage_return(line); info!( "MCP server stderr ({}): {}", self.program_name, @@ -330,10 +358,9 @@ impl ExecutorProcessTransport { } fn flush_stderr(&mut self) { - if self.stderr.is_empty() { + let Some(line) = self.stderr.take_remaining() else { return; - } - let line = take(&mut self.stderr); + }; info!( "MCP server stderr ({}): {}", self.program_name, @@ -341,14 +368,18 @@ impl ExecutorProcessTransport { ); } - fn trim_trailing_carriage_return(mut line: Vec) -> Vec { + fn trim_trailing_carriage_return(mut line: BytesMut) -> BytesMut { if line.last() == Some(&b'\r') { - line.pop(); + line.truncate(line.len() - 1); } line } } +#[cfg(test)] +#[path = "executor_process_transport_tests.rs"] +mod tests; + impl Drop for ExecutorProcessTransport { fn drop(&mut self) { if self.terminated { diff --git a/codex-rs/rmcp-client/src/executor_process_transport_tests.rs b/codex-rs/rmcp-client/src/executor_process_transport_tests.rs new file mode 100644 index 0000000000..aeef11a764 --- /dev/null +++ b/codex-rs/rmcp-client/src/executor_process_transport_tests.rs @@ -0,0 +1,72 @@ +use bytes::BytesMut; +use pretty_assertions::assert_eq; + +use super::LineBuffer; + +#[test] +fn searches_only_new_bytes_after_partial_line() { + let mut buffer = LineBuffer::default(); + + buffer.extend_from_slice(b"partial"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial"[..]), + scanned_len: 7, + } + ); + + buffer.extend_from_slice(b" line"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial line"[..]), + scanned_len: 12, + } + ); + + buffer.extend_from_slice(b"\nnext"); + assert_eq!( + buffer.take_line(), + Some(BytesMut::from(&b"partial line"[..])) + ); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"next"[..]), + scanned_len: 0, + } + ); +} + +#[test] +fn splits_multiple_lines_and_retains_partial_tail() { + let mut buffer = LineBuffer::default(); + buffer.extend_from_slice(b"first\nsecond\npartial"); + + assert_eq!(buffer.take_line(), Some(BytesMut::from(&b"first"[..]))); + assert_eq!(buffer.take_line(), Some(BytesMut::from(&b"second"[..]))); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial"[..]), + scanned_len: 7, + } + ); +} + +#[test] +fn takes_unterminated_remaining_bytes_at_eof() { + let mut buffer = LineBuffer::default(); + buffer.extend_from_slice(b"remaining"); + assert_eq!(buffer.take_line(), None); + + assert_eq!( + buffer.take_remaining(), + Some(BytesMut::from(&b"remaining"[..])) + ); + assert_eq!(buffer, LineBuffer::default()); +} From d81fcdf8ef3f2857db1a51b83a012638a7ecf0fb Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 4 Jun 2026 10:12:08 -0500 Subject: [PATCH 024/141] Reduce SQLite contention from OpenTelemetry SDK debug logs (#26396) ## Summary - skip `opentelemetry_sdk` DEBUG and TRACE events before formatting or queueing them for the SQLite log sink - preserve INFO, WARN, and ERROR events from the SDK, along with TRACE events from application targets - add a persistence-level regression test for the target and level policy ## Why OpenTelemetry's batch log processor emits internal `BatchLogProcessor.ExportingDueToTimer` meta-events every second per Codex process. In measured high-fanout `logs_2.sqlite` databases, low-level `opentelemetry_sdk` events accounted for over 30% of retained rows (30-60% on the machines of people I asked to check). Persisting this SDK bookkeeping across many processes adds substantial write volume and contention without representing application activity. ## Validation - `just test -p codex-state` (132/132 tests passed, plus bench smoke) - `just fix -p codex-state` - `just fmt` --- codex-rs/state/src/log_db.rs | 15 +++++++ codex-rs/state/src/log_db_filter_tests.rs | 53 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 codex-rs/state/src/log_db_filter_tests.rs diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 6024d6bdaa..71d2afbc01 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -189,6 +189,17 @@ where fn on_event(&self, event: &Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) { let metadata = event.metadata(); + // The SDK emits DEBUG timer meta-events every second per process; these + // were over 30% of retained logs in measured high-fanout Codex environments. + if metadata.target() == "opentelemetry_sdk" + && matches!( + *metadata.level(), + tracing::Level::TRACE | tracing::Level::DEBUG + ) + { + return; + } + let mut visitor = MessageVisitor::default(); event.record(&mut visitor); let thread_id = visitor @@ -451,6 +462,10 @@ impl Visit for MessageVisitor { } } +#[cfg(test)] +#[path = "log_db_filter_tests.rs"] +mod filter_tests; + #[cfg(test)] mod tests { use std::io; diff --git a/codex-rs/state/src/log_db_filter_tests.rs b/codex-rs/state/src/log_db_filter_tests.rs new file mode 100644 index 0000000000..36ee27aba8 --- /dev/null +++ b/codex-rs/state/src/log_db_filter_tests.rs @@ -0,0 +1,53 @@ +use pretty_assertions::assert_eq; +use tracing_subscriber::filter::Targets; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use uuid::Uuid; + +use super::*; + +#[tokio::test] +async fn sqlite_sink_drops_low_level_opentelemetry_sdk_logs() { + let codex_home = + std::env::temp_dir().join(format!("codex-state-log-db-filter-{}", Uuid::new_v4())); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + let layer = start(runtime.clone()); + + let guard = tracing_subscriber::registry() + .with( + layer + .clone() + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ) + .set_default(); + + tracing::trace!(target: "opentelemetry_sdk", "dropped-trace"); + tracing::debug!(target: "opentelemetry_sdk", "dropped-debug"); + tracing::info!(target: "opentelemetry_sdk", "retained-info"); + tracing::trace!(target: "codex_state", "retained-trace"); + + layer.flush().await; + drop(guard); + + let logs = runtime + .query_logs(&crate::LogQuery::default()) + .await + .expect("query logs after flush"); + assert_eq!( + logs.iter() + .map(|row| ( + row.level.as_str(), + row.target.as_str(), + row.message.as_deref() + )) + .collect::>(), + vec![ + ("INFO", "opentelemetry_sdk", Some("retained-info")), + ("TRACE", "codex_state", Some("retained-trace")), + ] + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; +} From 4ae7930f58c980c1064f5c2f7a2e38b1058e0410 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 4 Jun 2026 11:21:40 -0400 Subject: [PATCH 025/141] Load plugin hooks without other plugin capabilities (#26272) ## Summary `hooks/list` only consumes plugin hook declarations, but previously loaded every enabled plugin's skills, MCP configuration, apps, and capability summary before discarding them. In a local benchmark, this reduced `hooks/list` latency by over 100ms (e.g., from 594 to 467ms on startup, and 168 to 16ms when making a `hooks/list` call later in the same TUI session). This is on the critical path to rendering the TUI, so every 10s of ms should be eyed skeptically (IMO). This change adds a hook-specific plugin loading path that preserves plugin enablement, remote/local conflict resolution, deterministic ordering, manifest resolution, and hook-loading warnings while skipping unrelated capabilities. (I think there's room for a more general design here that allows you to project the capabilities you need at load-time, but that seems unnecessary right now.) --- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/catalog_processor.rs | 10 +- codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/loader.rs | 151 +++++++++++------ codex-rs/core-plugins/src/loader_tests.rs | 153 ++++++++++++++++++ codex-rs/core-plugins/src/manager.rs | 20 +++ codex-rs/core-plugins/src/manager_tests.rs | 47 ++++++ 7 files changed, 330 insertions(+), 53 deletions(-) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 36008ce0b9..2f3c87d868 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -303,7 +303,6 @@ use codex_core::windows_sandbox::WindowsSandboxSetupRequest; use codex_core::windows_sandbox::sandbox_setup_is_complete; use codex_core_plugins::PluginInstallError as CorePluginInstallError; use codex_core_plugins::PluginInstallRequest; -use codex_core_plugins::PluginLoadOutcome; use codex_core_plugins::PluginReadRequest; use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; use codex_core_plugins::loader::load_plugin_apps; diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 6f17e4614f..bbba0bae62 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -645,20 +645,20 @@ impl CatalogRequestProcessor { .await; let plugins_enabled = config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; - let plugin_outcome = if plugins_enabled { + let plugin_hooks = if plugins_enabled { let plugins_input = config.plugins_config_input(); plugins_manager - .plugins_for_layer_stack(&config.config_layer_stack, &plugins_input) + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &plugins_input) .await } else { - PluginLoadOutcome::default() + codex_core_plugins::PluginHookLoadOutcome::default() }; let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), bypass_hook_trust: config.bypass_hook_trust, config_layer_stack: Some(config.config_layer_stack), - plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), - plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + plugin_hook_sources: plugin_hooks.hook_sources, + plugin_hook_load_warnings: plugin_hooks.hook_load_warnings, ..Default::default() }); data.push(codex_app_server_protocol::HooksListEntry { diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 4728a8c6f8..98cb73e127 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -26,6 +26,7 @@ pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome, + pub hook_load_warnings: Vec, +} + +enum PluginLoadScope<'a> { + AllCapabilities { + restriction_product: Option, + skill_config_rules: &'a SkillConfigRules, + }, + HooksOnly, +} + #[derive(Clone, Copy, PartialEq, Eq)] enum NonCuratedCacheRefreshMode { IfVersionChanged, @@ -117,6 +132,26 @@ pub async fn load_plugins_from_layer_stack( prefer_remote_curated_conflicts: bool, ) -> PluginLoadOutcome { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); + load_plugins_from_layer_stack_with_scope( + config_layer_stack, + extra_plugins, + store, + prefer_remote_curated_conflicts, + PluginLoadScope::AllCapabilities { + restriction_product, + skill_config_rules: &skill_config_rules, + }, + ) + .await +} + +async fn load_plugins_from_layer_stack_with_scope( + config_layer_stack: &ConfigLayerStack, + extra_plugins: HashMap, + store: &PluginStore, + prefer_remote_curated_conflicts: bool, + scope: PluginLoadScope<'_>, +) -> PluginLoadOutcome { let configured_plugins = merge_configured_plugins_with_remote_installed( configured_plugins_from_stack(config_layer_stack), extra_plugins, @@ -129,14 +164,7 @@ pub async fn load_plugins_from_layer_stack( let mut plugins = Vec::with_capacity(configured_plugins.len()); let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { - let loaded_plugin = load_plugin( - configured_name.clone(), - &plugin, - store, - restriction_product, - &skill_config_rules, - ) - .await; + let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store, &scope).await; for name in loaded_plugin.mcp_servers.keys() { if let Some(previous_plugin) = seen_mcp_server_names.insert(name.clone(), configured_name.clone()) @@ -155,6 +183,27 @@ pub async fn load_plugins_from_layer_stack( PluginLoadOutcome::from_plugins(plugins) } +/// Load hooks from enabled plugins without loading their skills, MCP servers, or apps. +pub async fn load_plugin_hooks_from_layer_stack( + config_layer_stack: &ConfigLayerStack, + extra_plugins: HashMap, + store: &PluginStore, + prefer_remote_curated_conflicts: bool, +) -> PluginHookLoadOutcome { + let outcome = load_plugins_from_layer_stack_with_scope( + config_layer_stack, + extra_plugins, + store, + prefer_remote_curated_conflicts, + PluginLoadScope::HooksOnly, + ) + .await; + PluginHookLoadOutcome { + hook_sources: outcome.effective_plugin_hook_sources(), + hook_load_warnings: outcome.effective_plugin_hook_warnings(), + } +} + fn merge_configured_plugins_with_remote_installed( mut configured_plugins: HashMap, extra_plugins: HashMap, @@ -557,8 +606,7 @@ async fn load_plugin( config_name: String, plugin: &PluginConfig, store: &PluginStore, - restriction_product: Option, - skill_config_rules: &SkillConfigRules, + scope: &PluginLoadScope<'_>, ) -> LoadedPlugin { let plugin_id = PluginId::parse(&config_name); let active_plugin_root = plugin_id @@ -616,46 +664,55 @@ async fn load_plugin( }; let manifest_paths = &manifest.paths; - loaded_plugin.manifest_name = manifest - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .or_else(|| Some(manifest.name.clone())); - loaded_plugin.manifest_description = manifest.description.clone(); - loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); - let resolved_skills = load_plugin_skills( - &plugin_root, - &loaded_plugin_id, - manifest_paths, - restriction_product, - skill_config_rules, - ) - .await; - let has_enabled_skills = resolved_skills.has_enabled_skills(); - loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; - loaded_plugin.has_enabled_skills = has_enabled_skills; - let mut mcp_servers = HashMap::new(); - for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { - let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; - for (name, mut config) in plugin_mcp.mcp_servers { - if let Some(policy) = plugin.mcp_servers.get(&name) { - apply_plugin_mcp_server_policy(&mut config, policy); - } - if mcp_servers.insert(name.clone(), config).is_some() { - warn!( - plugin = %plugin_root.display(), - path = %mcp_config_path.display(), - server = name, - "plugin MCP file overwrote an earlier server definition" - ); + match scope { + PluginLoadScope::AllCapabilities { + restriction_product, + skill_config_rules, + } => { + loaded_plugin.manifest_name = manifest + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .or_else(|| Some(manifest.name.clone())); + loaded_plugin.manifest_description = manifest.description.clone(); + loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); + let resolved_skills = load_plugin_skills( + &plugin_root, + &loaded_plugin_id, + manifest_paths, + *restriction_product, + skill_config_rules, + ) + .await; + let has_enabled_skills = resolved_skills.has_enabled_skills(); + loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; + loaded_plugin.has_enabled_skills = has_enabled_skills; + let mut mcp_servers = HashMap::new(); + for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { + let plugin_mcp = + load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin.mcp_servers.get(&name) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + path = %mcp_config_path.display(), + server = name, + "plugin MCP file overwrote an earlier server definition" + ); + } + } } + loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; } + PluginLoadScope::HooksOnly => {} } - loaded_plugin.mcp_servers = mcp_servers; - loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; let (hook_sources, hook_load_warnings) = load_plugin_hooks( &plugin_root, &loaded_plugin_id, diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index a360d54ccb..2e89776b49 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::manifest::load_plugin_manifest; +use crate::test_support::write_file; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigRequirements; @@ -65,6 +66,158 @@ fn configured_plugins_from_stack_merges_user_layers() { ); } +#[tokio::test] +async fn hooks_only_scope_shares_plugin_resolution_without_loading_other_capabilities() { + let temp_dir = TempDir::new().expect("tempdir"); + let plugin_root = temp_dir.path().join("plugins/cache/test/valid/local"); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"valid"}"#, + ); + write_file( + &plugin_root.join("skills/example/SKILL.md"), + "---\nname: example\ndescription: example skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{"mcpServers":{"example":{"command":"echo"}}}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{"apps":{"example":{"id":"connector_example"}}}"#, + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ] + } +}"#, + ); + + let disabled_root = temp_dir.path().join("plugins/cache/test/disabled/local"); + write_file( + &disabled_root.join(".codex-plugin/plugin.json"), + r#"{"name":"disabled"}"#, + ); + write_file( + &disabled_root.join("hooks/hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo disabled"}]}]}}"#, + ); + + let malformed_root = temp_dir.path().join("plugins/cache/test/malformed/local"); + write_file( + &malformed_root.join(".codex-plugin/plugin.json"), + "not valid json", + ); + + let warning_root = temp_dir.path().join("plugins/cache/test/warning/local"); + write_file( + &warning_root.join(".codex-plugin/plugin.json"), + r#"{"name":"warning"}"#, + ); + write_file(&warning_root.join("hooks/hooks.json"), "not valid json"); + + let stack = ConfigLayerStack::new( + vec![user_layer( + user_config_path(&temp_dir, "config.toml"), + r#" +[plugins."valid@test"] +enabled = true + +[plugins."disabled@test"] +enabled = false + +[plugins.invalid] +enabled = true + +[plugins."malformed@test"] +enabled = true + +[plugins."missing@test"] +enabled = true + +[plugins."warning@test"] +enabled = true +"#, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + let store = PluginStore::new(temp_dir.path().to_path_buf()); + + let full = load_plugins_from_layer_stack( + &stack, + HashMap::new(), + &store, + Some(Product::Codex), + /*prefer_remote_curated_conflicts*/ false, + ) + .await; + let hooks_only = load_plugins_from_layer_stack_with_scope( + &stack, + HashMap::new(), + &store, + /*prefer_remote_curated_conflicts*/ false, + PluginLoadScope::HooksOnly, + ) + .await; + + let validation_state = |outcome: &PluginLoadOutcome| { + outcome + .plugins() + .iter() + .map(|plugin| { + ( + plugin.config_name.clone(), + plugin.enabled, + plugin.root.clone(), + plugin.error.clone(), + ) + }) + .collect::>() + }; + assert_eq!(validation_state(&hooks_only), validation_state(&full)); + assert_eq!( + hooks_only.effective_plugin_hook_sources(), + full.effective_plugin_hook_sources() + ); + assert_eq!( + hooks_only.effective_plugin_hook_warnings(), + full.effective_plugin_hook_warnings() + ); + + let full_valid = full + .plugins() + .iter() + .find(|plugin| plugin.config_name == "valid@test") + .expect("full load should include valid plugin"); + assert!(full_valid.manifest_name.is_some()); + assert!(!full_valid.skill_roots.is_empty()); + assert!(!full_valid.mcp_servers.is_empty()); + assert!(!full_valid.apps.is_empty()); + + let hooks_only_valid = hooks_only + .plugins() + .iter() + .find(|plugin| plugin.config_name == "valid@test") + .expect("hooks-only load should include valid plugin"); + assert_eq!(hooks_only_valid.manifest_name, None); + assert!(hooks_only_valid.skill_roots.is_empty()); + assert!(hooks_only_valid.mcp_servers.is_empty()); + assert!(hooks_only_valid.apps.is_empty()); +} + #[test] fn plugin_mcp_file_supports_mcp_servers_object_format() { let parsed = serde_json::from_str::( diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 8022c6c86d..08f668d130 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -2,11 +2,13 @@ use super::PluginLoadOutcome; use super::startup_remote_sync::start_startup_remote_plugin_sync_once; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; +use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps; use crate::loader::load_plugin_hooks; +use crate::loader::load_plugin_hooks_from_layer_stack; use crate::loader::load_plugin_mcp_servers; use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; @@ -543,6 +545,24 @@ impl PluginsManager { .await } + /// Resolve plugin hooks for a config layer stack without loading other plugin capabilities. + pub async fn plugin_hooks_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + config: &PluginsConfigInput, + ) -> PluginHookLoadOutcome { + if !config.plugins_enabled { + return PluginHookLoadOutcome::default(); + } + load_plugin_hooks_from_layer_stack( + config_layer_stack, + self.remote_installed_plugin_configs(), + &self.store, + config.remote_plugin_enabled, + ) + .await + } + /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. pub async fn effective_skill_roots_for_layer_stack( &self, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 905d120a8e..d6c13b1fe0 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3858,3 +3858,50 @@ async fn load_plugins_ignores_project_config_files() { assert_eq!(outcome, PluginLoadOutcome::default()); } + +#[tokio::test] +async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ] + } +}"#, + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + let config = load_config(codex_home.path(), codex_home.path()).await; + + let outcome = PluginsManager::new(codex_home.path().to_path_buf()) + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + + assert_eq!(outcome.hook_sources.len(), 1); + assert_eq!( + outcome.hook_sources[0].source_relative_path, + "hooks/hooks.json" + ); + assert_eq!(outcome.hook_load_warnings, Vec::::new()); +} From 68db0bb5ec67635d3052ddae2ecb35cdea48263e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 4 Jun 2026 09:16:03 -0700 Subject: [PATCH 026/141] Simplify Codex CLI README (#26313) ## Summary The codex-rs README was left over from before we moved the docs into the developer site. Its contents were very much out of date, and we received some bug reports about it. --- codex-rs/README.md | 100 +-------------------------------------------- 1 file changed, 2 insertions(+), 98 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 18bffd9f64..8c2ae94040 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -1,99 +1,3 @@ -# Codex CLI (Rust Implementation) +# Codex CLI -We provide Codex CLI as a standalone executable to ensure a zero-dependency install. - -## Installing Codex - -Today, the easiest way to install Codex is via `npm`: - -```shell -npm i -g @openai/codex -codex -``` - -You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). - -## Documentation quickstart - -- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management). -- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md). - -## What's new in the Rust CLI - -The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported. - -### Config - -Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details. - -### Model Context Protocol Support - -#### MCP client - -Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details. - -#### MCP server (experimental) - -Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent. - -Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: - -```shell -npx @modelcontextprotocol/inspector codex mcp-server -``` - -Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly. - -### Notifications - -You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. - -### `codex exec` to run Codex programmatically/non-interactively - -To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. -Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk. - -### Experimenting with the Codex Sandbox - -To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI: - -``` -# Uses the sandbox implementation for the current host OS: -# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows. -codex sandbox [COMMAND]... - -# macOS-only diagnostic option -codex sandbox --log-denials [COMMAND]... -``` - -`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer -`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed -command. - -### Selecting a sandbox policy via `--sandbox` - -The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option: - -```shell -# Run Codex with the default, read-only sandbox -codex --sandbox read-only - -# Allow the agent to write within the current workspace while still blocking network access -codex --sandbox workspace-write - -# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env) -codex --sandbox danger-full-access -``` - -In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. - -## Code Organization - -This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates: - -- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex. -- [`exec/`](./exec) "headless" CLI for use in automation. -- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/). -- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands. - -If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned. +[**Codex CLI Documentation**](https://developers.openai.com/codex/cli) From 12e8764a9c61dfc8cc8ba2520a2b7cf3e0500edd Mon Sep 17 00:00:00 2001 From: Won Park Date: Thu, 4 Jun 2026 09:39:20 -0700 Subject: [PATCH 027/141] Add saved image path hint to standalone image generation (#25947) ## Why Standalone image generation returns image bytes to the model, but the model also needs the host artifact path to reference the generated file in follow-up work. ## What changed - Append the default saved-image path hint alongside the generated image tool output. - Reuse the existing core image-generation hint text. - Pass the thread ID and Codex home directory needed to compute the artifact path. - Add app-server and extension coverage for the model-visible hint. ## Validation - `just fmt` - `just bazel-lock-check` - `just test -p codex-app-server standalone_image_generation_returns_saved_path_hint_to_model` --- codex-rs/Cargo.lock | 1 + .../tests/suite/v2/imagegen_extension.rs | 19 +++++-- codex-rs/code-mode/src/description.rs | 1 + codex-rs/code-mode/src/runtime/callbacks.rs | 52 ++++++++++++++++++ codex-rs/code-mode/src/runtime/globals.rs | 3 ++ codex-rs/code-mode/src/service.rs | 38 +++++++++++++ .../context/image_generation_instructions.rs | 26 +++++++-- codex-rs/core/src/context/mod.rs | 1 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/stream_events_utils.rs | 3 +- codex-rs/ext/image-generation/Cargo.toml | 1 + .../image-generation/imagegen_description.md | 1 + .../ext/image-generation/src/extension.rs | 14 +++-- codex-rs/ext/image-generation/src/tests.rs | 52 +++++++++++++++++- codex-rs/ext/image-generation/src/tool.rs | 54 +++++++++++++++++-- 15 files changed, 249 insertions(+), 18 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f5e8b56230..1e79865d43 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3052,6 +3052,7 @@ dependencies = [ "codex-model-provider-info", "codex-protocol", "codex-tools", + "codex-utils-absolute-path", "http 1.4.0", "pretty_assertions", "schemars 0.8.22", diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 3469bf44c1..b6593bc820 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -44,7 +44,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] -async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> { +async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> { let call_id = "image-run-1"; let server = responses::start_mock_server().await; mount_image_response(&server).await; @@ -124,7 +124,13 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> "detail": "high", }) ); - assert_eq!(output["output"].as_array().map(Vec::len), Some(1)); + let output_hint = output["output"][1]["text"] + .as_str() + .context("image output should include model-visible path hint")?; + assert!( + output_hint.contains(&saved_path.display().to_string()), + "output hint should identify the path core saved" + ); assert!( !requests[1] .message_input_texts("developer") @@ -199,7 +205,7 @@ const result = await tools.image_gen__imagegen({ action: "generate", prompt: "paint a blue whale", }); -image(result); +generatedImage(result); "#, ), responses::ev_completed("resp-1"), @@ -246,7 +252,12 @@ image(result); "detail": "high", }) ); - assert_eq!(output["output"].as_array().map(Vec::len), Some(2)); + assert!( + output["output"][2]["text"] + .as_str() + .is_some_and(|text| text.contains("Generated images are saved")) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(3)); Ok(()) } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 50fcc034ba..7e0801ed25 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -25,6 +25,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. - `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index 7d8a28662a..dde63617ec 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -129,6 +129,58 @@ pub(super) fn image_callback( retval.set(v8::undefined(scope).into()); } +pub(super) fn generated_image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let output_hint = match generated_image_output_hint(scope, value) { + Ok(output_hint) => output_hint, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + if let Some(text) = output_hint { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + } + retval.set(v8::undefined(scope).into()); +} + +fn generated_image_output_hint( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let object = v8::Local::::try_from(value) + .map_err(|_| "generatedImage expects an image generation result object".to_string())?; + let key = v8::String::new(scope, "output_hint") + .ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?; + let output_hint = object + .get(scope, key.into()) + .ok_or_else(|| "failed to read generatedImage output_hint".to_string())?; + if output_hint.is_undefined() { + return Ok(None); + } + if !output_hint.is_string() { + return Err("generatedImage output_hint must be a string when provided".to_string()); + } + Ok(Some(output_hint.to_rust_string_lossy(scope))) +} + pub(super) fn store_callback( scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs index 2ec6953f09..fe3df4c958 100644 --- a/codex-rs/code-mode/src/runtime/globals.rs +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -1,6 +1,7 @@ use super::RuntimeState; use super::callbacks::clear_timeout_callback; use super::callbacks::exit_callback; +use super::callbacks::generated_image_callback; use super::callbacks::image_callback; use super::callbacks::load_callback; use super::callbacks::notify_callback; @@ -23,6 +24,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; let text = helper_function(scope, "text", text_callback)?; let image = helper_function(scope, "image", image_callback)?; + let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?; let store = helper_function(scope, "store", store_callback)?; let load = helper_function(scope, "load", load_callback)?; let notify = helper_function(scope, "notify", notify_callback)?; @@ -35,6 +37,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St set_global(scope, global, "setTimeout", set_timeout.into())?; set_global(scope, global, "text", text.into())?; set_global(scope, global, "image", image.into())?; + set_global(scope, global, "generatedImage", generated_image.into())?; set_global(scope, global, "store", store.into())?; set_global(scope, global, "load", load.into())?; set_global(scope, global, "notify", notify.into())?; diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 6589e6ec5a..b348d1d35f 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1563,6 +1563,44 @@ image({ ); } + #[tokio::test] + async fn generated_image_helper_appends_image_and_output_hint() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +generatedImage({ + image_url: "https://example.com/image.jpg", + output_hint: "generated image save hint", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ], + error_text: None, + } + ); + } + #[tokio::test] async fn image_helper_second_arg_overrides_explicit_object_detail() { let service = CodeModeService::new(); diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 52ce1ca4e9..6986a7c64f 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -1,6 +1,27 @@ use super::ContextualUserFragment; use std::fmt::Display; +/// Maximum size of the extension's model-facing generated-image path hint. +const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024; + +/// Returns the extension's model-facing hint, or omits it if the path makes it too large. +pub fn extension_image_generation_output_hint( + image_output_dir: impl Display, + image_output_path: impl Display, +) -> Option { + let hint = image_generation_hint(image_output_dir, image_output_path); + (hint.len() <= MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES).then_some(hint) +} + +fn image_generation_hint( + image_output_dir: impl Display, + image_output_path: impl Display, +) -> String { + format!( + "Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it." + ) +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ImageGenerationInstructions { image_output_dir: String, @@ -30,9 +51,6 @@ impl ContextualUserFragment for ImageGenerationInstructions { } fn body(&self) -> String { - format!( - "Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - self.image_output_dir, self.image_output_path - ) + image_generation_hint(&self.image_output_dir, &self.image_output_path) } } diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 5115bda6d4..78fc70fd49 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -44,6 +44,7 @@ pub(crate) use environment_context::EnvironmentContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; +pub use image_generation_instructions::extension_image_generation_output_hint; pub use internal_model_context::InternalContextSource; pub use internal_model_context::InternalModelContextFragment; pub use internal_model_context::InvalidInternalContextSource; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d3031ad222..10b3511d5d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -99,6 +99,7 @@ pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; +pub use stream_events_utils::image_generation_artifact_path; pub mod test_support; mod unified_exec; pub mod windows_sandbox; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 20fc6787ac..81beea5be1 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -38,7 +38,8 @@ use tracing::warn; const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; -pub(crate) fn image_generation_artifact_path( +/// Returns the host-owned default artifact path for a generated image. +pub fn image_generation_artifact_path( codex_home: &AbsolutePathBuf, session_id: &str, call_id: &str, diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index 995f1786bc..3f22bd3fff 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -23,6 +23,7 @@ codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } +codex-utils-absolute-path = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md index 7ae6ecb5fa..1e5a46390f 100644 --- a/codex-rs/ext/image-generation/imagegen_description.md +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -4,6 +4,7 @@ The `image_gen.imagegen` tool enables image generation from descriptions and edi - The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting). Guidelines: +- In code mode, pass the result to `generatedImage(result)`. - Set `action` to `generate` when the user asks for a brand new image. - Set `action` to `edit` when the user asks to modify an existing image from the conversation history. - Directly generate the image without reconfirmation or clarification. diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index f7a9f2406f..6f6a7c7b1b 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -13,6 +13,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use crate::backend::CodexImagesBackend; use crate::tool::ImageGenerationTool; @@ -26,6 +27,7 @@ struct ImageGenerationExtension { struct ImageGenerationExtensionConfig { enabled: bool, provider: ModelProviderInfo, + codex_home: AbsolutePathBuf, } impl From<&Config> for ImageGenerationExtensionConfig { @@ -35,6 +37,7 @@ impl From<&Config> for ImageGenerationExtensionConfig { enabled: config.features.enabled(Feature::ImageGenExt) && config.model_provider.is_openai(), provider: config.model_provider.clone(), + codex_home: config.codex_home.clone(), } } } @@ -76,9 +79,14 @@ impl ToolContributor for ImageGenerationExtension { return Vec::new(); } - vec![Arc::new(ImageGenerationTool::new(CodexImagesBackend::new( - create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())), - )))] + vec![Arc::new(ImageGenerationTool::new( + CodexImagesBackend::new(create_model_provider( + config.provider.clone(), + Some(self.auth_manager.clone()), + )), + config.codex_home.clone(), + thread_store.level_id().to_string(), + ))] } } diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 44f2d80ebe..6a974e2d51 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -3,6 +3,7 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_core::context::extension_image_generation_output_hint; use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; @@ -54,9 +55,58 @@ fn generate_uses_fixed_request_defaults() { } #[test] -fn generated_output_returns_image_input() { +fn generated_output_returns_image_input_and_output_hint() { + let output_hint = + extension_image_generation_output_hint("/tmp", "/tmp/call-1.png").expect("hint should fit"); let output = GeneratedImageOutput { result: RESULT.to_string(), + output_hint: Some(output_hint.clone()), + }; + + let ResponseInputItem::FunctionCallOutput { + output: response_output, + .. + } = output.to_response_item("call-1", &function_payload()) + else { + panic!("imagegen should return function tool output"); + }; + let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else { + panic!("imagegen output should contain generated image bytes"); + }; + assert_eq!( + content_items, + vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { text: output_hint }, + ] + ); +} + +#[test] +fn generated_output_returns_generated_image_helper_input_in_code_mode() { + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: Some("generated image save hint".to_string()), + }; + + assert_eq!( + output.code_mode_result(&function_payload()), + serde_json::json!({ + "image_url": format!("data:image/png;base64,{RESULT}"), + "output_hint": "generated image save hint", + }) + ); +} + +#[test] +fn generated_output_omits_oversized_output_hint() { + let long_path = "x".repeat(1024); + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: extension_image_generation_output_hint("/tmp", long_path), }; let ResponseInputItem::FunctionCallOutput { diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index e4e8e13135..9c73e96b23 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -3,6 +3,8 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_core::context::extension_image_generation_output_hint; +use codex_core::image_generation_artifact_path; use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; @@ -25,6 +27,7 @@ use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; @@ -42,12 +45,22 @@ const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md"); #[derive(Clone)] pub(crate) struct ImageGenerationTool { backend: CodexImagesBackend, + codex_home: AbsolutePathBuf, + thread_id: String, } impl ImageGenerationTool { /// Creates an image-generation tool backed by an image API executor. - pub(crate) fn new(backend: CodexImagesBackend) -> Self { - Self { backend } + pub(crate) fn new( + backend: CodexImagesBackend, + codex_home: AbsolutePathBuf, + thread_id: String, + ) -> Self { + Self { + backend, + codex_home, + thread_id, + } } } @@ -116,7 +129,17 @@ impl ToolExecutor for ImageGenerationTool { saved_path: None, })) .await; - Ok(Box::new(GeneratedImageOutput { result })) + let output_path = + image_generation_artifact_path(&self.codex_home, &self.thread_id, &call.call_id); + let output_dir = output_path + .parent() + .unwrap_or_else(|| self.codex_home.clone()); + let output_hint = + extension_image_generation_output_hint(output_dir.display(), output_path.display()); + Ok(Box::new(GeneratedImageOutput { + result, + output_hint, + })) } } @@ -297,6 +320,7 @@ fn imagegen_tool_spec() -> ToolSpec { struct GeneratedImageOutput { result: String, + output_hint: Option, } impl ToolOutput for GeneratedImageOutput { @@ -310,12 +334,32 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes for model follow-up. + /// Returns the object consumed by the code-mode `generatedImage()` helper. + fn code_mode_result(&self, _payload: &ToolPayload) -> Value { + let mut result = Map::from_iter([( + "image_url".to_string(), + Value::String(format!("data:image/png;base64,{}", self.result)), + )]); + if let Some(output_hint) = &self.output_hint { + result.insert( + "output_hint".to_string(), + Value::String(output_hint.clone()), + ); + } + Value::Object(result) + } + + /// Returns generated bytes and persisted-artifact context for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let content = vec![FunctionCallOutputContentItem::InputImage { + let mut content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), detail: Some(DEFAULT_IMAGE_DETAIL), }]; + if let Some(output_hint) = &self.output_hint { + content.push(FunctionCallOutputContentItem::InputText { + text: output_hint.clone(), + }); + } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { From cbf62f64cbfdca160e7303536ec6ea464d371281 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Thu, 4 Jun 2026 13:13:05 -0400 Subject: [PATCH 028/141] Bound external agent session detection work (#26291) ## Why External agent migration detection parsed and hashed every JSONL session file. For users with many large conversations, launching migration could consume substantial CPU and disk resources. Detection only needs the most recent sessions for the migration UI, so full-content work should be bounded. ## What - Use file modification metadata to select the 50 most recent eligible sessions before parsing JSONL content. - Skip unchanged imported sessions using metadata stored in the import ledger. - Preserve content hashing when metadata indicates a session may have changed. - Stream SHA-256 calculation through a 64 KiB buffer instead of loading an entire session into memory. - Continue detecting older sessions in subsequent batches after newer sessions are imported. ## Validation - `RUST_MIN_STACK=8388608 cargo nextest run --no-fail-fast -p codex-external-agent-sessions` - 20 tests passed. - Benchmarked release builds against 250 valid JSONL sessions totaling 501 MiB: - Median detection time decreased from 1,138.8 ms to 47.0 ms. - CPU instructions decreased by 95.8%. - Both versions returned the expected 50 sessions. The benchmark used warm filesystem caches and measured the reduction in parsing, hashing, and CPU work. --- .../external-agent-sessions/src/detect.rs | 209 +++++++++++++++--- .../external-agent-sessions/src/ledger.rs | 112 ++++++++-- 2 files changed, 271 insertions(+), 50 deletions(-) diff --git a/codex-rs/external-agent-sessions/src/detect.rs b/codex-rs/external-agent-sessions/src/detect.rs index 27613eed18..f42c90766b 100644 --- a/codex-rs/external-agent-sessions/src/detect.rs +++ b/codex-rs/external-agent-sessions/src/detect.rs @@ -1,7 +1,10 @@ use crate::ExternalAgentSessionMigration; use crate::ledger::load_import_ledger; +use crate::ledger::save_import_ledger; use crate::now_unix_seconds; use crate::summarize_session; +use std::cmp::Reverse; +use std::collections::BinaryHeap; use std::fs; use std::io; use std::path::Path; @@ -10,12 +13,6 @@ use std::time::Duration; const SESSION_IMPORT_MAX_COUNT: usize = 50; const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60); -#[derive(Debug)] -struct SessionCandidate { - latest_timestamp: i64, - migration: ExternalAgentSessionMigration, -} - pub fn detect_recent_sessions( external_agent_home: &Path, codex_home: &Path, @@ -26,8 +23,9 @@ pub fn detect_recent_sessions( } let now = now_unix_seconds(); - let ledger = load_import_ledger(codex_home)?; - let mut candidates = Vec::new(); + let mut ledger = load_import_ledger(codex_home)?; + let source_states = ledger.source_states(); + let mut file_candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1); for project_entry in fs::read_dir(projects_root)? { let Ok(project_entry) = project_entry else { continue; @@ -47,44 +45,67 @@ pub fn detect_recent_sessions( if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { continue; } - let Ok(Some(summary)) = summarize_session(&path) else { + let Ok(metadata) = entry.metadata() else { continue; }; - let Ok(has_been_imported) = ledger.contains_current_source(&path) else { + let Ok(modified_at) = metadata.modified() else { continue; }; - if has_been_imported { + let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else { + continue; + }; + if (modified_at.as_secs() as i64) + < now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64) + { continue; } - if !is_recent_enough(now, summary.latest_timestamp) { + let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else { + continue; + }; + let Ok(source_path) = fs::canonicalize(&path) else { + continue; + }; + if let Some(state) = source_states.get(source_path.as_path()) + && (state.source_modified_at == Some(modified_at_nanos) + || state.source_modified_at.is_none() + && modified_at.as_secs() as i64 <= state.imported_at) + { continue; } - let migration = summary.migration; - if !migration.cwd.is_dir() { - continue; + file_candidates.push((Reverse(modified_at_nanos), path)); + if file_candidates.len() > SESSION_IMPORT_MAX_COUNT { + file_candidates.pop(); } - candidates.push(SessionCandidate { - latest_timestamp: summary.latest_timestamp, - migration, - }); } } - candidates.sort_by(|left, right| { - right - .latest_timestamp - .cmp(&left.latest_timestamp) - .then_with(|| left.migration.path.cmp(&right.migration.path)) - }); - candidates.truncate(SESSION_IMPORT_MAX_COUNT); - Ok(candidates - .into_iter() - .map(|candidate| candidate.migration) - .collect()) -} + drop(source_states); + let file_candidates = file_candidates.into_sorted_vec(); + let mut migrations = Vec::new(); + let mut ledger_changed = false; + for (modified_at, path) in file_candidates { + match ledger.refresh_current_source(&path, modified_at.0) { + Ok(false) => {} + Ok(true) => { + ledger_changed = true; + continue; + } + Err(_) => continue, + } + let Ok(Some(summary)) = summarize_session(&path) else { + continue; + }; + let migration = summary.migration; + if !migration.cwd.is_dir() { + continue; + } + migrations.push(migration); + } + if ledger_changed { + save_import_ledger(codex_home, &ledger)?; + } -fn is_recent_enough(now: i64, latest_timestamp: i64) -> bool { - latest_timestamp >= now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64) + Ok(migrations) } #[cfg(test)] @@ -93,7 +114,10 @@ mod tests { use crate::ledger::record_imported_session; use codex_protocol::ThreadId; use serde_json::Value as JsonValue; + use std::fs::FileTimes; + use std::fs::OpenOptions; use std::path::Path; + use std::time::SystemTime; use tempfile::TempDir; #[test] @@ -207,11 +231,11 @@ mod tests { } #[test] - fn ignores_old_sessions() { + fn uses_file_modification_time_for_recency() { let root = TempDir::new().expect("tempdir"); let external_agent_home = root.path().join(".external"); let project_root = root.path().join("repo"); - write_session( + let session_path = write_session( &external_agent_home, &project_root, "session.jsonl", @@ -223,6 +247,34 @@ mod tests { )], ); + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!( + sessions, + vec![ExternalAgentSessionMigration { + path: session_path, + cwd: project_root, + title: Some("hello".to_string()), + }] + ); + } + + #[test] + fn ignores_sessions_with_old_file_modification_time() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let session_path = write_session( + &external_agent_home, + &project_root, + "session.jsonl", + &[record("user", "hello", &project_root)], + ); + set_modified_at( + &session_path, + SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1), + ); + assert!( detect_recent_sessions(&external_agent_home, root.path()) .expect("detect") @@ -230,6 +282,84 @@ mod tests { ); } + #[test] + fn detects_sessions_in_batches() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let modified_at = SystemTime::now(); + let mut expected = Vec::new(); + for index in 0..=SESSION_IMPORT_MAX_COUNT { + let file_name = format!("{index:02}-session.jsonl"); + let title = format!("session {index}"); + let path = write_session( + &external_agent_home, + &project_root, + &file_name, + &[record_at("user", &title, &project_root, ×tamp)], + ); + set_modified_at( + &path, + modified_at - Duration::from_secs(/*secs*/ index as u64), + ); + expected.push(ExternalAgentSessionMigration { + path, + cwd: project_root.clone(), + title: Some(title), + }); + } + let oldest_session = expected.pop().expect("oldest session"); + let mut all_sessions = expected.clone(); + all_sessions.push(oldest_session.clone()); + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, expected); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, vec![oldest_session.clone()]); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let changed_at = SystemTime::now() + + Duration::from_secs(/*secs*/ SESSION_IMPORT_MAX_COUNT as u64 + 1); + for (index, session) in all_sessions.iter().enumerate() { + let title = session.title.as_deref().expect("session title"); + std::fs::write( + &session.path, + jsonl(&[ + record("user", title, &project_root), + record("assistant", "updated", &project_root), + ]), + ) + .expect("update session"); + set_modified_at( + &session.path, + changed_at - Duration::from_secs(/*secs*/ index as u64), + ); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, expected); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, vec![oldest_session]); + } + #[test] fn skips_already_imported_current_session_versions() { let root = TempDir::new().expect("tempdir"); @@ -300,6 +430,15 @@ mod tests { session_path } + fn set_modified_at(path: &Path, modified_at: SystemTime) { + OpenOptions::new() + .write(true) + .open(path) + .expect("open session") + .set_times(FileTimes::new().set_modified(modified_at)) + .expect("set session modified time"); + } + fn record(role: &str, text: &str, cwd: &Path) -> JsonValue { let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); record_at(role, text, cwd, ×tamp) diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 84679581ca..45ff97bcf0 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -4,12 +4,16 @@ use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +use std::collections::HashMap; use std::fs; +use std::fs::File; use std::io; +use std::io::Read; use std::path::Path; use std::path::PathBuf; const SESSION_IMPORT_LEDGER_FILE: &str = "external_agent_session_imports.json"; +const SESSION_HASH_BUFFER_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub(super) struct ImportedExternalAgentSessionLedger { @@ -22,6 +26,14 @@ struct ImportedExternalAgentSessionRecord { content_sha256: String, imported_thread_id: ThreadId, imported_at: i64, + #[serde(default)] + source_modified_at: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ImportedSourceState { + pub source_modified_at: Option, + pub imported_at: i64, } pub fn has_current_session_been_imported( @@ -39,30 +51,82 @@ pub fn record_imported_session( let mut ledger = load_import_ledger(codex_home)?; let source_path = canonical_source_path(source_path)?; let content_sha256 = session_content_sha256(&source_path)?; - if ledger - .records - .iter() - .any(|record| record.source_path == source_path && record.content_sha256 == content_sha256) - { - return Ok(()); + let source_modified_at = session_modified_at(&source_path)?; + if let Some(index) = ledger.records.iter().rposition(|record| { + record.source_path == source_path && record.content_sha256 == content_sha256 + }) { + let mut record = ledger.records.remove(index); + record.imported_thread_id = imported_thread_id; + record.imported_at = now_unix_seconds(); + record.source_modified_at = source_modified_at; + ledger.records.push(record); + } else { + ledger.records.push(ImportedExternalAgentSessionRecord { + source_path, + content_sha256, + imported_thread_id, + imported_at: now_unix_seconds(), + source_modified_at, + }); } - ledger.records.push(ImportedExternalAgentSessionRecord { - source_path, - content_sha256, - imported_thread_id, - imported_at: now_unix_seconds(), - }); save_import_ledger(codex_home, &ledger) } impl ImportedExternalAgentSessionLedger { + pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> { + let mut states = HashMap::new(); + for record in &self.records { + states.insert( + record.source_path.as_path(), + ImportedSourceState { + source_modified_at: record.source_modified_at, + imported_at: record.imported_at, + }, + ); + } + states + } + pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { let source_path = canonical_source_path(source_path)?; + if !self + .records + .iter() + .any(|record| record.source_path == source_path) + { + return Ok(false); + } let content_sha256 = session_content_sha256(&source_path)?; Ok(self.records.iter().any(|record| { record.source_path == source_path && record.content_sha256 == content_sha256 })) } + + pub(super) fn refresh_current_source( + &mut self, + source_path: &Path, + source_modified_at: i64, + ) -> io::Result { + let source_path = canonical_source_path(source_path)?; + if !self + .records + .iter() + .any(|record| record.source_path == source_path) + { + return Ok(false); + } + let content_sha256 = session_content_sha256(&source_path)?; + let Some(index) = self.records.iter().rposition(|record| { + record.source_path == source_path && record.content_sha256 == content_sha256 + }) else { + return Ok(false); + }; + let mut record = self.records.remove(index); + record.imported_at = now_unix_seconds(); + record.source_modified_at = Some(source_modified_at); + self.records.push(record); + Ok(true) + } } pub(super) fn load_import_ledger( @@ -84,7 +148,7 @@ pub(super) fn load_import_ledger( }) } -fn save_import_ledger( +pub(super) fn save_import_ledger( codex_home: &Path, ledger: &ImportedExternalAgentSessionLedger, ) -> io::Result<()> { @@ -103,6 +167,24 @@ fn canonical_source_path(path: &Path) -> io::Result { } fn session_content_sha256(path: &Path) -> io::Result { - let contents = fs::read(path)?; - Ok(format!("{:x}", Sha256::digest(contents))) + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0; SESSION_HASH_BUFFER_SIZE]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let digest = hasher.finalize(); + Ok(format!("{digest:x}")) +} + +fn session_modified_at(path: &Path) -> io::Result> { + Ok(fs::metadata(path)? + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_nanos()).ok())) } From cdc1d592df7f066c141025cc8ae80bb3202580b6 Mon Sep 17 00:00:00 2001 From: mpc-oai Date: Thu, 4 Jun 2026 12:20:32 -0500 Subject: [PATCH 029/141] Expose configured marketplace source in plugin list JSON (#26417) ## Summary - Follow-up to #25330 - Add `marketplaceSource` to `codex plugin list --json` entries for configured marketplaces - Keep the existing per-plugin `source` field unchanged; this still reports the local plugin source path - Include only the configured marketplace `sourceType` and `source` from `config.toml` - Keep human-readable output unchanged - Add CLI coverage for configured local and git marketplace sources Example: ```json { "source": { "source": "local", "path": "/path/to/.codex/.tmp/marketplaces/debug/plugins/sample" }, "marketplaceSource": { "sourceType": "git", "source": "https://example.com/acme/agent-skills.git" } } ``` ## Validation - `just fmt` - `just fix -p codex-cli` - `just test -p codex-cli plugin_list` --- codex-rs/cli/src/plugin_cmd.rs | 58 +++++++++++++++++++++++++- codex-rs/cli/tests/plugin_cli.rs | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 9ed2c1be6e..c850556e4d 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -20,6 +20,7 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -177,9 +178,14 @@ pub async fn run_plugin_list( .is_none_or(|name| marketplace.name == *name) }) .collect::>(); + let marketplace_sources = configured_marketplace_sources(&plugins_input); if args.json { - let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + let output = JsonPluginListOutput::from_marketplaces( + marketplaces, + args.available, + &marketplace_sources, + ); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -269,13 +275,19 @@ impl JsonPluginListOutput { fn from_marketplaces( marketplaces: Vec, include_available: bool, + marketplace_sources: &HashMap, ) -> Self { let mut installed = Vec::new(); let mut available = Vec::new(); for marketplace in marketplaces { + let marketplace_source = marketplace_sources.get(&marketplace.name).cloned(); for plugin in marketplace.plugins { - let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + let entry = JsonPluginListEntry::from_configured_plugin( + &marketplace.name, + marketplace_source.clone(), + plugin, + ); if entry.installed { installed.push(entry); } else if include_available { @@ -301,6 +313,8 @@ struct JsonPluginListEntry { installed: bool, enabled: bool, source: JsonPluginSource, + #[serde(skip_serializing_if = "Option::is_none")] + marketplace_source: Option, install_policy: &'static str, auth_policy: &'static str, } @@ -308,6 +322,7 @@ struct JsonPluginListEntry { impl JsonPluginListEntry { fn from_configured_plugin( marketplace_name: &str, + marketplace_source: Option, plugin: codex_core_plugins::ConfiguredMarketplacePlugin, ) -> Self { let version = plugin.installed_version.or(plugin.local_version); @@ -319,6 +334,7 @@ impl JsonPluginListEntry { installed: plugin.installed, enabled: plugin.enabled, source: JsonPluginSource::from_marketplace_source(plugin.source), + marketplace_source, install_policy: install_policy_label(plugin.policy.installation), auth_policy: auth_policy_label(plugin.policy.authentication), } @@ -375,6 +391,44 @@ impl JsonPluginSource { } } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceSource { + source_type: String, + source: String, +} + +fn configured_marketplace_sources( + plugins_input: &PluginsConfigInput, +) -> HashMap { + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { + return HashMap::new(); + }; + let Some(marketplaces) = user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return HashMap::new(); + }; + + marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + let source_type = marketplace + .get("source_type") + .and_then(toml::Value::as_str)?; + let source = marketplace.get("source").and_then(toml::Value::as_str)?; + Some(( + marketplace_name.clone(), + JsonMarketplaceSource { + source_type: source_type.to_string(), + source: source.to_string(), + }, + )) + }) + .collect() +} + fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { match policy { MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 4ce3617211..2625e0b68b 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -495,6 +496,7 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; let plugin_path = source.path().join("plugins").join("sample"); + let source_path = source.path().to_string_lossy().into_owned(); let assert = codex_command(codex_home.path())? .args(["plugin", "list", "--available", "--json"]) @@ -519,6 +521,69 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> { + let codex_home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let plugin_path = marketplace_root.join("plugins").join("sample"); + let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--available", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [], + "available": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": false, + "enabled": false, + "source": { + "source": "local", + "path": normalized_plugin_path.display().to_string(), + }, + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, @@ -533,6 +598,7 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() async fn plugin_list_json_prints_installed_plugins() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; let plugin_path = source.path().join("plugins").join("sample"); + let source_path = source.path().to_string_lossy().into_owned(); codex_command(codex_home.path())? .args(["plugin", "add", "sample@debug"]) @@ -561,6 +627,10 @@ async fn plugin_list_json_prints_installed_plugins() -> Result<()> { "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, From 881cf191d72da0edfc90cbc9fd5b3c30cb6eb9bc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 4 Jun 2026 11:05:54 -0700 Subject: [PATCH 030/141] app-server: support -c config overrides (#26436) ## Why The standalone `codex-app-server` binary already routed a `CliConfigOverrides` value into app-server startup, but its own clap args did not expose the shared `-c/--config` option. That meant `codex-app-server -c key=value` was rejected before the existing config override path could run, unlike the main `codex` CLI. ## What Changed - Flatten `CliConfigOverrides` into `AppServerArgs` in `codex-rs/app-server/src/main.rs`. - Pass parsed overrides to `run_main_with_transport_options` instead of always using `CliConfigOverrides::default()`. - Add a binary parser test covering both `-c` and `--config` for the standalone app-server. ## Verification - `just test -p codex-app-server app_server_accepts_cli_config_overrides` The broader `just test -p codex-app-server` run was also attempted. It compiled and ran 812 tests, with 796 passing, but failed in this local sandbox on unrelated `sandbox-exec: sandbox_apply: Operation not permitted` command-exec/turn integration paths and a skills watcher timeout. --- codex-rs/app-server/src/main.rs | 10 +++++++- codex-rs/app-server/src/main_tests.rs | 37 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server/src/main_tests.rs diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 2435c532e8..2412f8c3cf 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -19,6 +19,9 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C #[derive(Debug, Parser)] #[command(version)] struct AppServerArgs { + #[command(flatten)] + config_overrides: CliConfigOverrides, + /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( @@ -58,6 +61,7 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let AppServerArgs { + config_overrides, listen, session_source, auth, @@ -84,7 +88,7 @@ fn main() -> anyhow::Result<()> { run_main_with_transport_options( arg0_paths, - CliConfigOverrides::default(), + config_overrides, loader_overrides, strict_config, /*default_analytics_enabled*/ false, @@ -123,3 +127,7 @@ fn managed_config_path_from_debug_env() -> Option { None } + +#[cfg(test)] +#[path = "main_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/main_tests.rs b/codex-rs/app-server/src/main_tests.rs new file mode 100644 index 0000000000..57d0e5217c --- /dev/null +++ b/codex-rs/app-server/src/main_tests.rs @@ -0,0 +1,37 @@ +use super::AppServerArgs; +use clap::Parser; +use pretty_assertions::assert_eq; +use toml::Value as TomlValue; + +#[test] +fn app_server_accepts_cli_config_overrides() { + let args = AppServerArgs::try_parse_from([ + "codex-app-server", + "-c", + "model=\"gpt-5-codex\"", + "--config", + "sandbox_mode=\"read-only\"", + "--listen", + "off", + ]) + .expect("parse app-server args"); + + let parsed_overrides = args + .config_overrides + .parse_overrides() + .expect("parse config overrides"); + + assert_eq!( + parsed_overrides, + vec![ + ( + "model".to_string(), + TomlValue::String("gpt-5-codex".to_string()), + ), + ( + "sandbox_mode".to_string(), + TomlValue::String("read-only".to_string()), + ), + ] + ); +} From c8fdc74b421fb904e11c07886a3a7dc22fa56ed6 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Thu, 4 Jun 2026 14:16:03 -0400 Subject: [PATCH 031/141] external-agent-migration: avoid mixed MCP transport configs (#26435) ## Why MCP migration could recursively merge an imported server into an existing same-named Codex server. When one definition used stdio and the other used HTTP, this produced an invalid mixed configuration containing both `command` and `url`. ## What changed - Merge MCP configuration at the server level instead of field by field. - Preserve an existing same-named Codex MCP server unchanged. - Report only MCP servers that would actually be added during detection. - Add regression coverage for mixed command/HTTP source configurations. - Use neutral fixture names and reserved `example.com` URLs. ## Test plan - `just test -p codex-app-server repo_mcp` - 5 tests passed. - `just test -p codex-external-agent-migration mcp_migration_prefers_command_transport_for_mixed_server_config` - 1 test passed. --- .../src/config/external_agent_config.rs | 48 ++++++- .../src/config/external_agent_config_tests.rs | 118 ++++++++++++++++++ codex-rs/external-agent-migration/src/lib.rs | 43 +++++++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 18c276f07d..eca927fc23 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -316,9 +316,8 @@ impl ExternalAgentConfigService { Some(self.external_agent_home.as_path()), mcp_settings.as_ref(), )?; - let mcp_server_names = migrated_mcp_server_names(&migrated_mcp); + let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp); if !is_empty_toml_table(&migrated_mcp) { - let mut should_include = true; if target_config.exists() { let existing_raw = fs::read_to_string(&target_config)?; let mut existing = if existing_raw.trim().is_empty() { @@ -328,10 +327,10 @@ impl ExternalAgentConfigService { invalid_data_error(format!("invalid existing config.toml: {err}")) })? }; - should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?; + mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?; } - if should_include { + if !mcp_server_names.is_empty() { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: format!( @@ -341,7 +340,7 @@ impl ExternalAgentConfigService { ), cwd: cwd.clone(), details: Some(MigrationDetails { - mcp_servers: named_migrations(mcp_server_names.clone()), + mcp_servers: named_migrations(mcp_server_names), ..Default::default() }), }); @@ -863,7 +862,7 @@ impl ExternalAgentConfigService { toml::from_str::(&existing_raw) .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))? }; - if merge_missing_toml_values(&mut existing, &migrated)? { + if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() { write_toml_file(&target_config, &existing)?; } Ok(()) @@ -1538,6 +1537,43 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> } } +fn merge_missing_mcp_servers( + existing: &mut TomlValue, + incoming: &TomlValue, +) -> io::Result> { + let existing_root = existing + .as_table_mut() + .ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?; + let incoming_root = incoming + .as_table() + .ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?; + let Some(incoming_servers) = incoming_root.get("mcp_servers") else { + return Ok(Vec::new()); + }; + let incoming_servers = incoming_servers + .as_table() + .ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?; + let Some(existing_servers) = existing_root.get_mut("mcp_servers") else { + existing_root.insert( + "mcp_servers".to_string(), + TomlValue::Table(incoming_servers.clone()), + ); + return Ok(incoming_servers.keys().cloned().collect()); + }; + let Some(existing_servers) = existing_servers.as_table_mut() else { + return Ok(Vec::new()); + }; + + let mut merged_server_names = Vec::new(); + for (server_name, incoming_server) in incoming_servers { + if !existing_servers.contains_key(server_name) { + existing_servers.insert(server_name.clone(), incoming_server.clone()); + merged_server_names.push(server_name.clone()); + } + } + Ok(merged_server_names) +} + fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> { let serialized = toml::to_string_pretty(value) .map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?; diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 47f93a5f8b..8c120fb51b 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -638,6 +638,124 @@ Research with Codex carefully.""" assert_eq!(agent, expected_agent); } +#[tokio::test] +async fn import_repo_mcp_preserves_existing_same_named_server() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "mixedTransport": { + "command": "mcp-remote-proxy", + "args": [ + "https://example.com/mixed-transport", + "--transport", + "http" + ], + "url": "https://example.com/mixed-transport" + } + } + }"#, + ) + .expect("write mcp"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + let existing_config = r#"[mcp_servers.mixedTransport] +url = "https://example.com/mixed-transport" +"#; + fs::write( + repo_root.join(".codex").join("config.toml"), + existing_config, + ) + .expect("write config"); + + let service = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ); + assert_eq!( + service + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"), + Vec::::new() + ); + + service + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + existing_config + ); +} + +#[tokio::test] +async fn detect_repo_mcp_lists_only_missing_servers() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "docs": {"command": "docs-server"}, + "mixedTransport": {"command": "mcp-remote-proxy"} + } + }"#, + ) + .expect("write mcp"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + fs::write( + repo_root.join(".codex").join("config.toml"), + r#"[mcp_servers.mixedTransport] +url = "https://example.com/mixed-transport" +"#, + ) + .expect("write config"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: format!( + "Migrate MCP servers from {} into {}", + repo_root.display(), + repo_root.join(".codex").join("config.toml").display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + mcp_servers: vec![NamedMigration { + name: "docs".to_string(), + }], + ..Default::default() + }), + }] + ); +} + #[tokio::test] async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { let (_root, external_agent_home, codex_home) = fixture_paths(); diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index d7b8801796..6ee0b38a24 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -1451,6 +1451,49 @@ mod tests { ); } + #[test] + fn mcp_migration_prefers_command_transport_for_mixed_server_config() { + let root = tempfile::TempDir::new().expect("tempdir"); + fs::write( + root.path().join(".mcp.json"), + r#"{ + "mcpServers": { + "mixedTransport": { + "command": "mcp-remote-proxy", + "args": [ + "https://example.com/mixed-transport", + "--transport", + "http" + ], + "url": "https://example.com/mixed-transport" + } + } + }"#, + ) + .expect("write mcp"); + + assert_eq!( + build_mcp_config_from_external( + root.path(), + /*external_agent_home*/ None, + /*settings*/ None, + ) + .unwrap(), + toml::from_str( + r#" +[mcp_servers.mixedTransport] +command = "mcp-remote-proxy" +args = [ + "https://example.com/mixed-transport", + "--transport", + "http", +] +"# + ) + .unwrap() + ); + } + #[test] fn mcp_migration_skips_unsupported_transports() { let root = tempfile::TempDir::new().expect("tempdir"); From 9e41f8ddbe087ae6b9bdfb8d5821ab2f09f8c5fe Mon Sep 17 00:00:00 2001 From: kbazzi Date: Thu, 4 Jun 2026 11:24:12 -0700 Subject: [PATCH 032/141] [codex-analytics] emit forked thread id on initialization (#26248) ## Why - Thread initialization analytics do not identify the source thread for forked threads. - The session viewer needs this lineage to construct thread trees. - Depends on openai/openai#987854. Do not release this change before that backend schema change is deployed. ## What Changed - Adds optional `forked_from_thread_id` to `codex_thread_initialized`. - Populates it from the existing thread fork lineage for app-server and in-process subagent initialization paths. - Keeps it null for non-forked threads. ## Verification - `just fmt` - `just test -p codex-analytics` - `just test -p codex-app-server thread_fork_tracks_thread_initialized_analytics` --- .../analytics/src/analytics_client_tests.rs | 23 +++++- codex-rs/analytics/src/events.rs | 2 + codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 2 + .../thread_processor_tests.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 7 ++ codex-rs/core/src/codex_thread.rs | 1 + codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 79 +++++++++++++++++++ 10 files changed, 119 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 59e3f50467..7177952c37 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() { initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, + forked_from_thread_id: None, created_at: 1, }, }); @@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() { "initialization_mode": "new", "subagent_source": null, "parent_thread_id": null, + "forked_from_thread_id": null, "created_at": 1 } }) @@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() { assert_eq!(payload["event_params"]["initialization_mode"], "new"); assert_eq!(payload["event_params"]["subagent_source"], "review"); assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + json!(null) + ); } #[test] -fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { +fn subagent_thread_started_thread_spawn_serializes_thread_lineage() { let parent_thread_id = codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") .expect("valid thread id"); + let forked_from_thread_id = + codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222") + .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: Some(forked_from_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + "22222222-2222-4222-8222-222222222222" + ); assert_eq!(payload["event_params"]["session_id"], "session-root"); } @@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "parent-client".to_string(), client_name: "parent-client".to_string(), client_version: "1.0.0".to_string(), @@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 5367e651e4..bdc2c996dc 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, + pub(crate) forked_from_thread_id: Option, pub(crate) created_at: u64, } @@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request( initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input.parent_thread_id, + forked_from_thread_id: input.forked_from_thread_id, created_at: input.created_at, }; ThreadInitializedEvent { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b056108507..1dff5de89d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput { pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, + pub forked_from_thread_id: Option, pub product_client_id: String, pub client_name: String, pub client_version: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5b41178530..877000928f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -1268,6 +1268,7 @@ impl AnalyticsReducer { let session_id = thread.session_id; let thread_id = thread.id; let parent_thread_id = thread.parent_thread_id; + let forked_from_thread_id = thread.forked_from_id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -1299,6 +1300,7 @@ impl AnalyticsReducer { initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id, + forked_from_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, }, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 6be022aa6f..c0a909e577 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -701,6 +701,7 @@ mod thread_processor_behavior_tests { }, }, session_source: SessionSource::Cli, + forked_from_thread_id: None, parent_thread_id: None, thread_source: None, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index ff9f655630..a335b441ff 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { "forked", "user", ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + thread + .forked_from_id + .as_deref() + .expect("forked thread has a source thread") + ); Ok(()) } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ac28a58ec3..2ed8a9ae65 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -70,6 +70,7 @@ pub struct ThreadConfigSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, + pub forked_from_thread_id: Option, pub parent_thread_id: Option, pub thread_source: Option, } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ea33a6bde..294ab2f211 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3364,6 +3364,9 @@ pub(crate) fn emit_subagent_session_started( session_id: session_id.to_string(), thread_id: thread_id.to_string(), parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()), + forked_from_thread_id: thread_config + .forked_from_thread_id + .map(|thread_id| thread_id.to_string()), product_client_id: client_name.clone(), client_name, client_version, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 07d249613a..756f87831a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -185,6 +185,7 @@ impl SessionConfiguration { personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), + forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, thread_source: self.thread_source, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1643574206..93dc1e109f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3831,6 +3831,85 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati } } +#[tokio::test] +async fn emit_subagent_session_started_includes_fork_lineage_from_session_configuration() { + 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/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let analytics_events_client = AnalyticsEventsClient::new( + auth_manager, + server.uri(), + /*analytics_enabled*/ Some(true), + ); + + let parent_thread_id = ThreadId::new(); + let forked_from_thread_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.forked_from_thread_id = Some(forked_from_thread_id); + + emit_subagent_session_started( + &analytics_events_client, + AppServerClientMetadata { + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + }, + SessionId::from(child_thread_id), + child_thread_id, + Some(parent_thread_id), + session_configuration.thread_config_snapshot(), + SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + ); + + let event = timeout(Duration::from_secs(1), async { + 'wait_for_event: loop { + if let Some(requests) = server.received_requests().await { + for request in requests { + let payload: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid analytics payload"); + if let Some(event) = payload["events"].as_array().and_then(|events| { + events + .iter() + .find(|event| event["event_type"] == "codex_thread_initialized") + }) { + break 'wait_for_event event.clone(); + } + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("subagent initialization analytics should be emitted"); + + assert_eq!( + event["event_params"]["parent_thread_id"], + parent_thread_id.to_string() + ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + forked_from_thread_id.to_string() + ); +} + fn turn_environments_for_tests( environment: &Arc, cwd: &codex_utils_absolute_path::AbsolutePathBuf, From 8b1238856b0839cfdb345ee2af9f02e4ee2959f9 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 4 Jun 2026 11:40:18 -0700 Subject: [PATCH 033/141] core: allow excluding tool namespaces from code mode (#26320) ## Why Research and training setups need to control which tool namespaces appear inside code mode's nested `tools` surface without disabling those tools entirely. This makes it possible to train against a deliberately reduced nested-tool setup while preserving the normal direct and deferred tool paths. ## What - Extend `features.code_mode` to accept structured configuration while preserving the existing boolean syntax. - Add an exact `excluded_tool_namespaces` list under `[features.code_mode]`: ```toml [features.code_mode] enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] ``` - Filter matching canonical `ToolName` namespaces when constructing code mode's nested router and code-mode-specific direct tool descriptions. - Keep excluded tools registered, directly exposed in mixed code mode, and discoverable through top-level `tool_search` when otherwise eligible. - Derive deferred nested-tool guidance after namespace filtering so the `exec` description does not advertise excluded-only deferred tools. - Preserve the boolean/table representation when materializing config locks and update the generated config schema. ## Testing - `just test -p codex-features` - `just test -p codex-config` - `just test -p codex-core load_config_resolves_code_mode_config` - `just test -p codex-core lock_contains_prompts_and_materializes_features` - `just test -p codex-core excluded_deferred_namespaces_do_not_enable_nested_tool_guidance` - `just test -p codex-core code_mode_excludes_configured_nested_tool_namespaces` - `cargo check -p codex-thread-manager-sample` --- codex-rs/config/src/schema.rs | 9 +++ codex-rs/core/config.schema.json | 30 +++++++- codex-rs/core/src/config/config_tests.rs | 26 +++++++ codex-rs/core/src/config/mod.rs | 29 ++++++++ codex-rs/core/src/session/config_lock.rs | 8 ++ codex-rs/core/src/tools/spec_plan.rs | 45 +++++++---- codex-rs/core/src/tools/spec_plan_tests.rs | 36 +++++++++ codex-rs/core/tests/suite/code_mode.rs | 87 ++++++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 20 +++++ codex-rs/features/src/lib.rs | 11 ++- codex-rs/thread-manager-sample/src/main.rs | 1 + 11 files changed, 286 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index e0d2c70671..6b012494d9 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -25,6 +25,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { if feature.id == codex_features::Feature::Artifact { continue; } + if feature.id == codex_features::Feature::CodeMode { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::MultiAgentV2 { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b281b44bb7..a584eae263 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -355,6 +355,22 @@ }, "type": "object" }, + "CodeModeConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded_tool_namespaces": { + "description": "Exact tool namespaces to omit from the code-mode nested tool surface.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ConfigProfile": { "additionalProperties": false, "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", @@ -410,7 +426,7 @@ "type": "boolean" }, "code_mode": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, "code_mode_only": { "type": "boolean" @@ -830,6 +846,16 @@ } ] }, + "FeatureToml_for_CodeModeConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/CodeModeConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -4533,7 +4559,7 @@ "type": "boolean" }, "code_mode": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, "code_mode_only": { "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dac8286749..ba622e8944 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -476,6 +476,32 @@ async fn load_config_resolves_experimental_request_user_input_enabled() -> std:: Ok(()) } +#[tokio::test] +async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { + let codex_home = tempdir()?; + let config_toml: ConfigToml = toml::from_str( + r#" +[features.code_mode] +enabled = true +excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +"#, + ) + .expect("TOML deserialization should succeed"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.code_mode.excluded_tool_namespaces, + vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] + ); + assert!(config.features.enabled(Feature::CodeMode)); + Ok(()) +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2c3cd4d6c3..e198443b16 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -58,6 +58,7 @@ use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; use codex_features::AppsMcpPathOverrideConfigToml; +use codex_features::CodeModeConfigToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -977,6 +978,9 @@ pub struct Config { /// Whether to register the experimental request_user_input tool. pub experimental_request_user_input_enabled: bool, + /// Configuration for the experimental code-mode tool surface. + pub code_mode: CodeModeConfig, + /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1029,6 +1033,11 @@ pub struct Config { pub otel: codex_config::types::OtelConfig, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CodeModeConfig { + pub excluded_tool_namespaces: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, @@ -2290,6 +2299,17 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) -> .is_none_or(|config| config.enabled) } +fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { + let base = code_mode_toml_config(config_toml.features.as_ref()); + + CodeModeConfig { + excluded_tool_namespaces: base + .and_then(|config| config.excluded_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), + } +} + fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config { let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); let default = MultiAgentV2Config::default(); @@ -2372,6 +2392,13 @@ fn resolve_optional_prompt_text( } } +fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> { + match features?.code_mode.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> { match features?.multi_agent_v2.as_ref()? { FeatureToml::Enabled(_) => None, @@ -3013,6 +3040,7 @@ impl Config { let web_search_config = resolve_web_search_config(&cfg); let experimental_request_user_input_enabled = resolve_experimental_request_user_input_enabled(&cfg); + let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); @@ -3554,6 +3582,7 @@ impl Config { web_search_mode: constrained_web_search_mode.value, web_search_config, experimental_request_user_input_enabled, + code_mode, use_experimental_unified_exec_tool, background_terminal_max_timeout, ghost_snapshot, diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 85815f8533..5d608ec900 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -251,6 +251,14 @@ mod tests { spec.key ); } + assert_eq!( + features.code_mode, + Some(FeatureToml::Enabled( + sc.original_config_do_not_use + .features + .enabled(Feature::CodeMode) + )) + ); let multi_agent_v2 = features .multi_agent_v2 diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 48111fb578..eae0644b83 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -207,7 +207,12 @@ fn build_model_visible_specs_and_registry( if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) { let spec = runtime.spec(); - specs.push(spec_for_model_request(turn_context, exposure, spec)); + specs.push(spec_for_model_request( + turn_context, + exposure, + &tool_name, + spec, + )); } } specs.extend(hosted_specs); @@ -226,12 +231,14 @@ fn build_model_visible_specs_and_registry( fn spec_for_model_request( turn_context: &TurnContext, exposure: ToolExposure, + tool_name: &ToolName, spec: ToolSpec, ) -> ToolSpec { if matches!( turn_context.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly ) && exposure != ToolExposure::DirectModelOnly + && !is_excluded_from_code_mode(turn_context, tool_name) && codex_code_mode::is_code_mode_nested_tool(spec.name()) { codex_tools::augment_tool_spec_for_code_mode(spec) @@ -405,10 +412,19 @@ fn is_hidden_by_code_mode_only( )) } +fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool { + tool_name.namespace.as_ref().is_some_and(|namespace| { + turn_context + .config + .code_mode + .excluded_tool_namespaces + .contains(namespace) + }) +} + fn build_code_mode_executors( turn_context: &TurnContext, executors: &[Arc], - deferred_tools_available: bool, ) -> Vec> { if !matches!( turn_context.tool_mode, @@ -419,6 +435,8 @@ fn build_code_mode_executors( let mut code_mode_nested_tool_specs = Vec::new(); let mut exec_prompt_tool_specs = Vec::new(); + let mut deferred_tools_available = false; + let deferred_tools_guidance_enabled = search_tool_enabled(turn_context); for executor in executors { let exposure = executor.exposure(); if exposure == ToolExposure::DirectModelOnly { @@ -428,9 +446,19 @@ fn build_code_mode_executors( if exposure == ToolExposure::Hidden { continue; } + + if is_excluded_from_code_mode(turn_context, &executor.tool_name()) { + continue; + } + let spec = executor.spec(); - if exposure != ToolExposure::Deferred { + if exposure == ToolExposure::Deferred { + // Only show deferred-tool guidance when supported and an included spec is usable by code mode. + deferred_tools_available |= deferred_tools_guidance_enabled + && !collect_code_mode_exec_prompt_tool_definitions(std::iter::once(&spec)) + .is_empty(); + } else { exec_prompt_tool_specs.push(spec.clone()); } code_mode_nested_tool_specs.push(spec); @@ -839,16 +867,7 @@ fn prepend_code_mode_executors( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - let deferred_tools_available = search_tool_enabled(turn_context) - && planned_tools - .runtimes() - .iter() - .any(|executor| executor.exposure() == ToolExposure::Deferred); - let code_mode_executors = build_code_mode_executors( - turn_context, - planned_tools.runtimes(), - deferred_tools_available, - ); + let code_mode_executors = build_code_mode_executors(turn_context, planned_tools.runtimes()); planned_tools.runtimes.splice(0..0, code_mode_executors); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 02a7a21f63..bf5b1c01bb 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -938,6 +938,42 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + set_feature(turn, Feature::Collab, /*enabled*/ false); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("excluded"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!( + !exec + .description + .contains("Some deferred nested tools may be omitted") + ); + plan.assert_registered_contains(&[ + &ToolName::namespaced("excluded", "lookup").to_string(), + "tool_search", + ]); +} + #[tokio::test] async fn multi_agent_feature_selects_one_agent_tool_family() { let v1 = probe(|turn| { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index e33f964aa5..ffe23bff56 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3165,6 +3165,93 @@ text( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_excludes_configured_nested_tool_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::CodeMode); + config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; + }); + let base_test = builder.build(&server).await?; + let new_thread = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![DynamicToolSpec { + namespace: Some("excluded".to_string()), + name: "lookup".to_string(), + description: "An excluded dynamic tool.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }], + ) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + + let first_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call( + "call-1", + "exec", + r#" +text(JSON.stringify({ + excludedType: typeof tools.excluded__lookup, + excludedMetadata: ALL_TOOLS.some(({ name }) => name === "excluded__lookup"), + allowedType: typeof tools.update_plan, + allowedMetadata: ALL_TOOLS.some(({ name }) => name === "update_plan"), +})); +"#, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("use exec to inspect nested tool namespaces") + .await?; + + assert!( + tool_names(&first_mock.single_request().body_json()).contains(&"excluded".to_string()), + "excluded namespace should remain directly exposed in mixed code mode" + ); + let request = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&request, "call-1"); + assert_ne!( + success, + Some(false), + "exec configured namespace exclusion failed unexpectedly: {output}" + ); + assert_eq!( + serde_json::from_str::(&output)?, + serde_json::json!({ + "excludedType": "undefined", + "excludedMetadata": false, + "allowedType": "function", + "allowedMetadata": true, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_print_content_only_mcp_tool_result_fields() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 6106431d77..b7f666c21d 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -4,6 +4,26 @@ use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CodeModeConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Exact tool namespaces to omit from the code-mode nested tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_tool_namespaces: Option>, +} + +impl FeatureConfig for CodeModeConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct MultiAgentV2ConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index ce1ee6940e..a1f5a25c00 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -17,6 +17,7 @@ use toml::Table; mod feature_configs; mod legacy; pub use feature_configs::AppsMcpPathOverrideConfigToml; +pub use feature_configs::CodeModeConfigToml; pub use feature_configs::MultiAgentV2ConfigToml; pub use feature_configs::NetworkProxyConfigToml; pub use feature_configs::NetworkProxyDomainPermissionToml; @@ -600,6 +601,8 @@ pub fn is_known_feature_key(key: &str) -> bool { /// Deserializable features table for TOML. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] pub struct FeaturesToml { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code_mode: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -620,6 +623,9 @@ impl Features { impl FeaturesToml { pub fn entries(&self) -> BTreeMap { let mut entries = self.entries.clone(); + if let Some(enabled) = self.code_mode.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::CodeMode.key().to_string(), enabled); + } if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } @@ -638,6 +644,7 @@ impl FeaturesToml { pub fn materialize_resolved_enabled(&mut self, features: &Features) { let Self { + code_mode, multi_agent_v2, apps_mcp_path_override, network_proxy, @@ -648,7 +655,9 @@ impl FeaturesToml { } for spec in FEATURES { let enabled = features.enabled(spec.id); - if spec.id == Feature::MultiAgentV2 { + if spec.id == Feature::CodeMode { + materialize_resolved_feature_enabled(code_mode, enabled); + } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); } else if spec.id == Feature::AppsMcpPathOverride { materialize_resolved_feature_enabled(apps_mcp_path_override, enabled); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 9305a6f668..47e0fc3911 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -266,6 +266,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R web_search_mode: Constrained::allow_any(WebSearchMode::Disabled), web_search_config: None, experimental_request_user_input_enabled: true, + code_mode: Default::default(), use_experimental_unified_exec_tool: false, background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), From c3fcb0e7457dc53aa4422ae4356e1fcc2a206ce6 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 4 Jun 2026 12:24:26 -0700 Subject: [PATCH 034/141] Use Azure artifact signing environment secrets (#25945) ## Why Windows release signing should read Azure signing credentials from the `azure-artifact-signing` environment instead of the old repo-level `AZURE_TRUSTED_SIGNING_*` names. The smoke runs confirmed the environment secrets resolve with the new `AZURE_ARTIFACT_SIGNING_*` names once the Windows signing job is attached to that environment. ## What Changed - Put the real Windows signing job in the `azure-artifact-signing` environment. - Switch the Windows signing action inputs from `AZURE_TRUSTED_SIGNING_*` to `AZURE_ARTIFACT_SIGNING_*`. - Drop the obsolete `workflow_call.secrets` declarations for the old repo-level secret names; the caller continues to use `secrets: inherit`. - Remove the temporary branch-trigger and Windows-only smoke-test workflow changes before finalizing this PR. ## Validation - `git diff --check -- .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` - `ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "yaml ok: #{f}" }' .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` --- .github/workflows/rust-release-windows.yml | 28 +++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 89bcd33ea1..b4c2b84c0a 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -6,19 +6,6 @@ on: release-lto: required: true type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -150,6 +137,9 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} + environment: + name: azure-artifact-signing + deployment: false timeout-minutes: 90 permissions: contents: read @@ -211,12 +201,12 @@ jobs: with: target: ${{ matrix.target }} binaries: ${{ env.WINDOWS_BINARIES }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + client-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} - name: Stage artifacts shell: bash From e64b469bbcf2c2a6bb575ef0a58368f02147e899 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 12:43:07 -0700 Subject: [PATCH 035/141] Route AGENTS.md loading through environment filesystems (#26205) ## Why Workspace-specific `AGENTS.md` loading needs to use the selected environment filesystem so remote workspaces and child agents read instructions from their actual environment instead of the host filesystem. The app-server should report the same instruction sources the initialized thread actually loaded, rather than independently rescanning configuration and filesystem state. ## What changed - Introduce `LoadedAgentsMd` to retain ordered user, project, and internal instructions with their provenance. - Load and canonicalize workspace `AGENTS.md` paths through the primary `EnvironmentManager` environment, then render the loaded instructions when constructing turn context. - Expose cached loaded instruction sources from initialized threads and use them for app-server start, resume, and fork responses. - Preserve global `CODEX_HOME` loading and separator behavior while excluding empty project files that did not supply model-visible instructions. - Add integration coverage for CLI injection, selected-environment provenance and rendering, empty environment selection, and cached sources on loaded-thread resume. ## Validation - `just test -p codex-core agents_md` - `just test -p codex-core selected_environment_sources_match_model_visible_instructions` - `just test -p codex-exec agents_md` - `just test -p codex-app-server instruction_sources` - `just test -p codex-app-server --status-level fail` --- .../request_processors/thread_processor.rs | 20 +- .../tests/suite/v2/thread_resume.rs | 76 ++++++ .../app-server/tests/suite/v2/thread_start.rs | 77 ++++++ codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/agents_md.rs | 204 ++++++++++----- codex-rs/core/src/agents_md_tests.rs | 242 ++++++++++++++++-- codex-rs/core/src/codex_thread.rs | 5 + codex-rs/core/src/config/config_tests.rs | 26 +- codex-rs/core/src/config/mod.rs | 6 +- codex-rs/core/src/guardian/review_session.rs | 3 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 24 +- codex-rs/core/src/session/session.rs | 6 +- codex-rs/core/src/session/turn_context.rs | 6 +- .../src/tools/handlers/multi_agents_tests.rs | 6 +- codex-rs/core/tests/suite/agents_md.rs | 53 ++++ codex-rs/core/tests/suite/client.rs | 7 +- .../core/tests/suite/compact_remote_parity.rs | 5 +- codex-rs/core/tests/suite/prompt_caching.rs | 29 ++- .../core/tests/suite/prompt_debug_tests.rs | 5 +- codex-rs/exec/tests/suite/agents_md.rs | 74 ++++++ codex-rs/exec/tests/suite/mod.rs | 1 + 22 files changed, 740 insertions(+), 137 deletions(-) create mode 100644 codex-rs/exec/tests/suite/agents_md.rs diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 265832f9b7..6b4df61aa1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -659,12 +659,6 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } - async fn instruction_sources_from_config(config: &Config) -> Vec { - codex_core::AgentsMdManager::new(config) - .instruction_sources(LOCAL_FS.as_ref()) - .await - } - async fn load_thread( &self, thread_id: &str, @@ -1043,7 +1037,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } - let instruction_sources = Self::instruction_sources_from_config(&config).await; let environments = environments.unwrap_or_else(|| { listener_task_context .thread_manager @@ -1113,6 +1106,7 @@ impl ThreadRequestProcessor { ) .await?; + let instruction_sources = thread.instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2524,13 +2518,12 @@ impl ThreadRequestProcessor { } }; - let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); match self .thread_manager .resume_thread_with_history( - config.clone(), + config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, @@ -2553,6 +2546,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } + let instruction_sources = codex_thread.instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2853,10 +2847,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let mut config_for_instruction_sources = self.config.as_ref().clone(); - config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); - let instruction_sources = - Self::instruction_sources_from_config(&config_for_instruction_sources).await; + let instruction_sources = existing_thread.instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3233,7 +3224,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; let fallback_model_provider = config.model_provider_id.clone(); - let instruction_sources = Self::instruction_sources_from_config(&config).await; let NewThread { thread_id, @@ -3284,6 +3274,8 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } + let instruction_sources = forked_thread.instruction_sources().await; + // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( self.ensure_conversation_listener( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index d18a405164..4e052041ad 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace = TempDir::new()?; + let project_agents = workspace.path().join("AGENTS.md"); + std::fs::write(&project_agents, "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { + thread, + instruction_sources, + .. + } = to_response::(start_resp)?; + let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + assert_eq!(instruction_sources, vec![project_agents.clone()]); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + std::fs::remove_file(project_agents.as_path())?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + instruction_sources, + .. + } = to_response::(resume_resp)?; + + assert_eq!(instruction_sources, vec![project_agents]); + + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7a7ed5522a..208af14299 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -359,6 +359,83 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( Ok(()) } +#[tokio::test] +async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let global_agents_path = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents_path, "global instructions")?; + let workspace = TempDir::new()?; + let project_agents_path = workspace.path().join("AGENTS.md"); + std::fs::write(project_agents_path, "")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + let instruction_sources = instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( + global_agents_path, + )?)]; + + assert_eq!(instruction_sources, expected_instruction_sources); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?; + let workspace = TempDir::new()?; + std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + assert!(instruction_sources.is_empty()); + + Ok(()) +} + #[cfg(windows)] fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { let path = path.as_ref(); diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index e87ee82f30..d1c7c7a18b 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -26,6 +26,7 @@ pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; +pub use codex_core::LoadedAgentsMd; pub use codex_core::McpManager; pub use codex_core::NewThread; pub use codex_core::StartThreadOptions; diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 8048552480..bc3740a46f 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,11 +23,9 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; -use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; -use dunce::canonicalize as normalize_path; use std::io; use toml::Value as TomlValue; use tracing::error; @@ -37,8 +35,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; /// Preferred local override for AGENTS.md instructions. pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; -/// When both `Config::instructions` and AGENTS.md docs are present, they will -/// be concatenated with the following separator. +/// When both user and project AGENTS.md docs are present, they will be +/// concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Resolves AGENTS.md files into model-visible user instructions and source @@ -47,11 +45,6 @@ pub struct AgentsMdManager<'a> { config: &'a Config, } -pub(crate) struct LoadedAgentsMd { - pub(crate) contents: String, - pub(crate) path: AbsolutePathBuf, -} - impl<'a> AgentsMdManager<'a> { pub fn new(config: &'a Config) -> Self { Self { config } @@ -81,10 +74,7 @@ impl<'a> AgentsMdManager<'a> { let contents = String::from_utf8_lossy(&data); let trimmed = contents.trim(); if !trimmed.is_empty() { - return Some(LoadedAgentsMd { - contents: trimmed.to_string(), - path, - }); + return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path)); } } None @@ -94,10 +84,10 @@ impl<'a> AgentsMdManager<'a> { /// single model-visible instruction string. pub(crate) async fn user_instructions( &self, - environment: Option<&Environment>, + environment: &Environment, startup_warnings: &mut Vec, - ) -> Option { - let fs = environment?.get_filesystem(); + ) -> Option { + let fs = environment.get_filesystem(); self.user_instructions_with_fs(fs.as_ref(), startup_warnings) .await } @@ -106,22 +96,13 @@ impl<'a> AgentsMdManager<'a> { &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> Option { + ) -> Option { let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; - let mut output = String::new(); - - if let Some(instructions) = self.config.user_instructions.clone() { - output.push_str(&instructions); - } + let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); match agents_md_docs { - Ok(Some(docs)) => { - if !output.is_empty() { - output.push_str(AGENTS_MD_SEPARATOR); - } - output.push_str(&docs); - } + Ok(Some(docs)) => loaded.entries.extend(docs.entries), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); @@ -129,50 +110,26 @@ impl<'a> AgentsMdManager<'a> { }; if self.config.features.enabled(Feature::ChildAgentsMd) { - if !output.is_empty() { - output.push_str("\n\n"); - } - output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + loaded.entries.push(InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }); } - if !output.is_empty() { - Some(output) - } else { - None - } - } - - /// Returns all instruction source files included in the current config. - pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut global_instruction_warnings = Vec::new(); - let mut paths = Self::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&self.config.codex_home), - &mut global_instruction_warnings, - ) - .await - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); - match self.agents_md_paths(fs).await { - Ok(agents_md_paths) => paths.extend(agents_md_paths), - Err(err) => { - tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); - } - } - paths + (!loaded.is_empty()).then_some(loaded) } /// Attempt to locate and load AGENTS.md documentation. /// - /// On success returns `Ok(Some(contents))` where `contents` is the - /// concatenation of all discovered docs. If no documentation file is found - /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as - /// `Err` so callers can decide how to handle them. + /// On success returns `Ok(Some(loaded))` where `loaded` contains every + /// discovered doc. If no documentation file is found the function returns + /// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can + /// decide how to handle them. async fn read_agents_md( &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> io::Result> { + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -185,7 +142,7 @@ impl<'a> AgentsMdManager<'a> { } let mut remaining: u64 = max_total as u64; - let mut parts: Vec = Vec::new(); + let mut loaded = LoadedAgentsMd::default(); for p in paths { if remaining == 0 { @@ -221,15 +178,18 @@ impl<'a> AgentsMdManager<'a> { let text = String::from_utf8_lossy(&data).to_string(); if !text.trim().is_empty() { - parts.push(text); + loaded.entries.push(InstructionEntry { + contents: text, + provenance: InstructionProvenance::Project(p), + }); remaining = remaining.saturating_sub(data.len() as u64); } } - if parts.is_empty() { + if loaded.is_empty() { Ok(None) } else { - Ok(Some(parts.join("\n\n"))) + Ok(Some(loaded)) } } @@ -247,8 +207,8 @@ impl<'a> AgentsMdManager<'a> { } let mut dir = self.config.cwd.clone(); - if let Ok(canon) = normalize_path(&dir) { - dir = AbsolutePathBuf::try_from(canon)?; + if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { + dir = canonical_dir; } let mut merged = TomlValue::Table(toml::map::Map::new()); @@ -348,6 +308,114 @@ impl<'a> AgentsMdManager<'a> { } } +/// Model-visible instructions loaded from AGENTS.md files and internal +/// guidance. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LoadedAgentsMd { + /// Ordered instructions and their provenance. + entries: Vec, +} + +impl LoadedAgentsMd { + /// Creates loaded instructions containing one user-level AGENTS.md entry. + pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self { + if contents.trim().is_empty() { + return Self::default(); + } + Self { + entries: vec![InstructionEntry { + contents, + provenance: InstructionProvenance::User(path), + }], + } + } + + /// Creates source-less user instructions for tests. + /// + /// This cannot be gated with `#[cfg(test)]` because integration tests + /// compile `codex-core` as a normal dependency without that configuration. + pub fn from_text_for_testing(contents: impl Into) -> Self { + let contents = contents.into(); + if contents.trim().is_empty() { + return Self::default(); + } + Self { + entries: vec![InstructionEntry { + contents, + provenance: InstructionProvenance::Internal, + }], + } + } + + fn is_empty(&self) -> bool { + self.entries + .iter() + .all(|entry| entry.contents.trim().is_empty()) + } + + /// Returns the concatenated model-visible instruction text. + pub fn text(&self) -> String { + let mut output = String::new(); + let mut previous_provenance: Option<&InstructionProvenance> = None; + for entry in &self.entries { + if let Some(previous_provenance) = previous_provenance { + // The project-doc marker tells the model where workspace-scoped + // instructions begin, so it is only needed on the transition + // from user or internal instructions to project instructions. + let separator = match (previous_provenance, &entry.provenance) { + ( + InstructionProvenance::User(_) | InstructionProvenance::Internal, + InstructionProvenance::Project(_), + ) => AGENTS_MD_SEPARATOR, + _ => "\n\n", + }; + output.push_str(separator); + } + output.push_str(&entry.contents); + previous_provenance = Some(&entry.provenance); + } + output + } + + /// Returns the AGENTS.md files that supplied instruction entries. + pub fn sources(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|entry| entry.provenance.path()) + } +} + +/// One model-visible instruction and its provenance. +#[derive(Clone, Debug, PartialEq, Eq)] +struct InstructionEntry { + /// Model-visible instruction text. + contents: String, + + /// Origin of the instruction. + provenance: InstructionProvenance, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum InstructionProvenance { + /// User-level instructions, normally loaded from CODEX_HOME. + User(AbsolutePathBuf), + + /// Workspace instructions discovered from project AGENTS.md files. + Project(AbsolutePathBuf), + + /// Instructions without a file source, including internally defined guidance. + Internal, +} + +impl InstructionProvenance { + fn path(&self) -> Option<&AbsolutePathBuf> { + match self { + Self::User(path) | Self::Project(path) => Some(path), + Self::Internal => None, + } + } +} + fn warn_invalid_utf8( path: &AbsolutePathBuf, data: &[u8], diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 2d6a5fa30b..95659511b0 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -16,6 +16,7 @@ async fn get_user_instructions(config: &Config) -> Option { AgentsMdManager::new(config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await + .map(|loaded| loaded.text()) } async fn agents_md_paths(config: &Config) -> std::io::Result> { @@ -53,7 +54,12 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new_user( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -96,7 +102,12 @@ async fn make_config_with_project_root_markers( config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new_user( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -115,17 +126,46 @@ async fn no_doc_file_returns_none() { assert!(res.is_none(), "Expected None when AGENTS.md is absent"); } -#[tokio::test] -async fn no_environment_returns_none() { - let tmp = tempfile::tempdir().expect("tempdir"); - let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await; +#[test] +fn empty_loaded_instructions_are_empty() { + let source = + AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path"); - let mut warnings = Vec::new(); - let res = AgentsMdManager::new(&config) - .user_instructions(/*environment*/ None, &mut warnings) - .await; + assert_eq!( + LoadedAgentsMd::new_user(String::new(), source.clone()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::new_user(" \n\t".to_string(), source), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(String::new()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(" \n\t"), + LoadedAgentsMd::default() + ); +} - assert_eq!(res, None); +#[test] +fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() { + let empty = LoadedAgentsMd { + entries: vec![InstructionEntry { + contents: String::new(), + provenance: InstructionProvenance::Internal, + }], + }; + let whitespace = LoadedAgentsMd { + entries: vec![InstructionEntry { + contents: " \n\t".to_string(), + provenance: InstructionProvenance::Internal, + }], + }; + + assert!(empty.is_empty()); + assert!(whitespace.is_empty()); } /// Small file within the byte-limit is returned unmodified. @@ -161,8 +201,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() { .await .expect("global doc expected"); - assert_eq!(loaded.contents, "global\u{FFFD} doc"); - assert_eq!(loaded.path, path); + assert_eq!( + loaded, + LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone()) + ); assert_invalid_utf8_warning(&warnings, "Global", path.as_path()); } @@ -177,7 +219,8 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { let res = AgentsMdManager::new(&config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await - .expect("doc expected"); + .expect("doc expected") + .text(); assert_eq!(res, "project\u{FFFD} doc"); let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); @@ -272,6 +315,33 @@ async fn merges_existing_instructions_with_agents_md() { assert_eq!(res, expected); } +#[tokio::test] +async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "user instructions".to_string(), + )); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + assert_eq!( + loaded.text(), + format!("user instructions{AGENTS_MD_SEPARATOR}project doc") + ); + assert_eq!(loaded.sources().collect::>(), vec![&project_agents]); +} + /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] @@ -310,8 +380,38 @@ async fn concatenates_root_and_cwd_docs() { let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await; cfg.cwd = nested.abs(); - let res = get_user_instructions(&cfg).await.expect("doc expected"); - assert_eq!(res, "root doc\n\ncrate doc"); + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("doc expected"); + let root_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), + ) + .expect("absolute root doc path"); + let crate_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), + ) + .expect("absolute crate doc path"); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "root doc".to_string(), + provenance: InstructionProvenance::Project(root_agents.clone()), + }, + InstructionEntry { + contents: "crate doc".to_string(), + provenance: InstructionProvenance::Project(crate_agents.clone()), + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!(loaded.text(), "root doc\n\ncrate doc"); + assert_eq!( + loaded.sources().collect::>(), + vec![&root_agents, &crate_agents] + ); } #[tokio::test] @@ -350,6 +450,38 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn child_agents_message_after_global_instructions_uses_plain_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents), + }, + InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!( + loaded.text(), + format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); +} + #[tokio::test] async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -360,15 +492,85 @@ async fn instruction_sources_include_global_before_agents_md_docs() { fs::create_dir_all(&cfg.codex_home).unwrap(); fs::write(&global_agents, "global doc").unwrap(); - let sources = AgentsMdManager::new(&cfg) - .instruction_sources(LOCAL_FS.as_ref()) - .await; + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); let project_agents = AbsolutePathBuf::try_from( dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), ) .expect("absolute project doc path"); - assert_eq!(sources, vec![global_agents, project_agents]); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents.clone()), + }, + InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc") + ); +} + +#[tokio::test] +async fn child_agents_message_after_project_docs_is_not_an_instruction_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + fs::create_dir_all(&cfg.codex_home).unwrap(); + fs::write(&global_agents, "global doc").unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents.clone()), + }, + InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }, + InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); } /// AGENTS.override.md is preferred over AGENTS.md when both are present. diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 2ed8a9ae65..f9f10881b5 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -549,6 +549,11 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + /// Returns the files that supplied the thread's loaded model instructions. + pub async fn instruction_sources(&self) -> Vec { + self.codex.instruction_sources().await + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ba622e8944..588eb7ac1b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -208,10 +208,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { #[tokio::test] async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { let codex_home = tempdir()?; - std::fs::write( - codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), - "\n global instructions \n", - )?; + let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME); + std::fs::write(&global_agents_path, "\n global instructions \n")?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -221,9 +219,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { .await?; let _ = config.features.enable(Feature::MemoryTool); + let user_instructions = config + .user_instructions + .as_ref() + .expect("global instructions expected"); + assert_eq!(user_instructions.text(), "global instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("global instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_path] ); Ok(()) } @@ -235,7 +238,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), "global instructions", )?; - let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME); + let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME); std::fs::write(&global_agents_override_path, "local override instructions")?; let config = Config::load_from_base_config_with_overrides( @@ -245,9 +248,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R ) .await?; + let user_instructions = config + .user_instructions + .as_ref() + .expect("global override instructions expected"); + assert_eq!(user_instructions.text(), "local override instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("local override instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_override_path] ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e198443b16..b74f2feefe 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,4 +1,5 @@ use crate::agents_md::AgentsMdManager; +pub use crate::agents_md::LoadedAgentsMd; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::path_utils::normalize_for_native_workdir; @@ -645,7 +646,7 @@ pub struct Config { pub show_raw_agent_reasoning: bool, /// User-provided instructions from AGENTS.md. - pub user_instructions: Option, + pub user_instructions: Option, /// Base instructions override. pub base_instructions: Option, @@ -2605,8 +2606,7 @@ impl Config { Some(&codex_home), &mut startup_warnings, ) - .await - .map(|loaded| loaded.contents); + .await; // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index c139a910a7..0de607de98 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -29,6 +29,7 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tracing::warn; +use crate::LoadedAgentsMd; use crate::codex_delegate::run_codex_thread_interactive; use crate::config::Config; use crate::config::Constrained; @@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey { permissions: Permissions, developer_instructions: Option, base_instructions: Option, - user_instructions: Option, + user_instructions: Option, compact_prompt: Option, cwd: AbsolutePathBuf, mcp_servers: Constrained>, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 10b3511d5d..b77f96cdb1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -131,6 +131,7 @@ pub(crate) mod agents_md; pub use agents_md::AgentsMdManager; pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; +pub use agents_md::LoadedAgentsMd; mod rollout; pub(crate) mod safety; mod session_rollout_init_error; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 294ab2f211..c0bf9629b7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -507,12 +507,13 @@ impl Codex { let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); - let user_instructions = AgentsMdManager::new(&config) - .user_instructions( - primary_environment.as_deref(), - &mut user_instruction_warnings, - ) - .await; + let user_instructions = if let Some(primary_environment) = primary_environment { + AgentsMdManager::new(&config) + .user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings) + .await + } else { + None + }; config.startup_warnings.extend(user_instruction_warnings); let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { @@ -800,6 +801,17 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn instruction_sources(&self) -> Vec { + let state = self.session.state.lock().await; + state + .session_configuration + .user_instructions + .as_ref() + .map_or_else(Vec::new, |instructions| { + instructions.sources().cloned().collect() + }) + } + pub(crate) async fn thread_environment_selections(&self) -> Vec { let state = self.session.state.lock().await; state.session_configuration.environments.clone() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 756f87831a..9314a743a5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,6 @@ use super::input_queue::InputQueue; use super::*; +use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; use crate::goals::GoalRuntimeState; use crate::skills::SkillError; @@ -54,8 +55,9 @@ pub(crate) struct SessionConfiguration { /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, - /// Model instructions that are appended to the base instructions. - pub(super) user_instructions: Option, + /// Model instructions that are appended to the base instructions and the + /// files that supplied them. + pub(super) user_instructions: Option, /// Personality preference for the model. pub(super) personality: Option, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index fea422cb4b..e62e447155 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,5 +1,6 @@ use super::*; use crate::SkillLoadOutcome; +use crate::agents_md::LoadedAgentsMd; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_core_skills::HostLoadedSkills; @@ -551,7 +552,10 @@ impl Session { app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), - user_instructions: session_configuration.user_instructions.clone(), + user_instructions: session_configuration + .user_instructions + .as_ref() + .map(LoadedAgentsMd::text), collaboration_mode: session_configuration.collaboration_mode.clone(), multi_agent_version, personality: session_configuration.personality, 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 d32bd5dd62..6eec6fa2ed 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::LoadedAgentsMd; use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; @@ -4399,7 +4400,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some("base-user".to_string()); + base_config.user_instructions = Some(LoadedAgentsMd::new_user( + "base-user".to_string(), + base_config.codex_home.join("AGENTS.md"), + )); turn.user_instructions = Some("resolved-user".to_string()); turn.config = Arc::new(base_config.clone()); let base_instructions = BaseInstructions { diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 276fa4bb51..318c288dc6 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -7,6 +8,8 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use std::sync::Arc; +use tempfile::TempDir; async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { let server = start_mock_server().await; @@ -139,3 +142,53 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_agents = home.path().join("AGENTS.md"); + std::fs::write(&global_agents, "global doc")?; + + let mut builder = test_codex() + .with_home(home) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &cwd.join("AGENTS.md"), + b"project doc".to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }); + let test = builder.build_with_remote_env(&server).await?; + let project_agents = test + .fs() + .canonicalize( + &test.executor_environment().cwd().join("AGENTS.md"), + /*sandbox*/ None, + ) + .await?; + let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![global_agents, project_agents] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("global doc\n\n--- project-doc ---\n\nproject doc")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c954a954fb..f430a5c1eb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,5 +1,6 @@ use codex_config::ConfigLayerStack; use codex_config::types::AuthCredentialsStoreMode; +use codex_core::LoadedAgentsMd; use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; @@ -364,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .with_home(codex_home.clone()) .with_config(|config| { // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -1180,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let codex = builder .build(&server) @@ -2212,7 +2213,7 @@ async fn includes_developer_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 22678fc46f..395f3158dd 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::path::PathBuf; use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ServiceTier; @@ -517,7 +518,9 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "PARITY_USER_INSTRUCTIONS", + )); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b9afa13852..2a42bd0a16 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::LoadedAgentsMd; use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS; @@ -122,7 +123,9 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -237,7 +240,9 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -319,7 +324,9 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -417,7 +424,9 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -709,7 +718,9 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -844,7 +855,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -985,7 +998,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index dc506bc474..bad7e1ab5a 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -21,7 +22,9 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some("Project-specific test instructions".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "Project-specific test instructions", + )); let input = build_prompt_input( config, diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs new file mode 100644 index 0000000000..2891721ae6 --- /dev/null +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -0,0 +1,74 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_includes_workspace_agents_md_in_request() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "workspace instructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("workspace instructions")), + "request should include workspace AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_prefers_workspace_agents_override_md() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "base instructions")?; + std::fs::write( + test.cwd_path().join("AGENTS.override.md"), + "override instructions", + )?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("override instructions")), + "request should include AGENTS.override.md instructions: {user_messages:?}" + ); + assert!( + user_messages + .iter() + .all(|text| !text.contains("base instructions")), + "request should exclude shadowed AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 6f86856327..e7452cc17a 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod add_dir; +mod agents_md; mod apply_patch; mod approval_policy; mod auth_env; From 555f8caeff42073fcdc52755491d785adcc81e4a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 13:03:47 -0700 Subject: [PATCH 036/141] [codex] Fix Windows sandbox build script lint (#26445) ## Why The Windows ARM64 Cargo clippy job on `main` is failing because workspace lints deny `clippy::expect_used`, and the `codex-windows-sandbox` build script used `expect()` while reading `CARGO_MANIFEST_DIR`. ## What changed `codex-rs/windows-sandbox-rs/build.rs` now returns `Result<(), String>` from `main()` and converts a missing `CARGO_MANIFEST_DIR` into an explicit build-script error. The non-Windows early return and Windows linker argument behavior are unchanged. ## Verification - `just clippy -p codex-windows-sandbox -- -D warnings` - `just test -p codex-windows-sandbox` --- codex-rs/windows-sandbox-rs/build.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/build.rs b/codex-rs/windows-sandbox-rs/build.rs index af5aec78c6..6ba79e6c75 100644 --- a/codex-rs/windows-sandbox-rs/build.rs +++ b/codex-rs/windows-sandbox-rs/build.rs @@ -4,18 +4,16 @@ use std::path::PathBuf; const SETUP_BIN: &str = "codex-windows-sandbox-setup"; const SETUP_MANIFEST: &str = "codex-windows-sandbox-setup.manifest"; -fn main() { +fn main() -> Result<(), String> { println!("cargo:rerun-if-changed={SETUP_MANIFEST}"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { - return; + return Ok(()); } - let manifest_path = PathBuf::from( - env::var_os("CARGO_MANIFEST_DIR") - .expect("CARGO_MANIFEST_DIR should be set for build scripts"), - ) - .join(SETUP_MANIFEST); + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .ok_or_else(|| "CARGO_MANIFEST_DIR should be set for build scripts".to_string())?; + let manifest_path = PathBuf::from(manifest_dir).join(SETUP_MANIFEST); let manifest_path = manifest_path.display(); // Keep this scoped to the setup helper so Codex binaries that link the @@ -36,4 +34,6 @@ fn main() { } _ => {} } + + Ok(()) } From f97d5c327538a8fe8580fa4dd9ee22c213780a59 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 13:07:53 -0700 Subject: [PATCH 037/141] build: use ThinLTO for release binaries (#23710) ## Why Fat LTO makes release builds substantially slower without providing enough measured runtime benefit to justify the release CI long pole. The build-profile investigation found that keeping Cargo's default release `opt-level=3` and switching from fat LTO to ThinLTO (`3/thin/1`) reduced a clean `codex-cli` release build from 2073.893 seconds to 1243.172 seconds, a 40.06% improvement. The resulting binary increased from 196.7 MiB to 211.8 MiB (+7.63%). Measured runtime changes were small: the worst image workload median was +0.86% and app-server startup was +0.31% relative to fat LTO. ThinLTO retains cross-crate optimization while avoiding most of the fat-LTO build cost. This deliberately avoids global size optimization: final-executable testing showed a substantial regression on the image request path, which is expected to become more important as image usage grows. ## What changed - Set the workspace release profile to `lto = "thin"`, retaining Cargo's default release `opt-level=3`. - Remove release and CI workflow-specific LTO overrides so release-profile builds consistently use the workspace setting. - Remove the now-unused Windows release workflow input and related diagnostic output. ## Validation - Confirmed the release profile parses with `cargo metadata --no-deps --format-version 1`. - CI validates release builds across the supported target matrix. --- .github/workflows/rust-ci-full.yml | 2 -- .github/workflows/rust-release-windows.yml | 11 ++--------- .github/workflows/rust-release.yml | 6 ------ codex-rs/Cargo.toml | 2 +- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 60d508b21d..50ecf2f8d6 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -167,8 +167,6 @@ jobs: USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G - # In rust-ci, representative release-profile checks use thin LTO for faster feedback. - CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }} strategy: fail-fast: false diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index b4c2b84c0a..14d38b9db5 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -2,10 +2,6 @@ name: rust-release-windows on: workflow_call: - inputs: - release-lto: - required: true - type: string # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -16,17 +12,14 @@ jobs: build-windows-binaries: name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on }} - # Windows release builds can exceed an hour on fat-LTO mainline releases, - # so keep the timeout aligned with the top-level release build headroom. + # Windows release builds can exceed an hour, so keep the timeout aligned + # with the top-level release build headroom. timeout-minutes: 90 permissions: contents: read defaults: run: working-directory: codex-rs - env: - CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - strategy: fail-fast: false matrix: diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 5e3b263ed0..2888ea6ce8 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -149,9 +149,6 @@ jobs: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -325,7 +322,6 @@ jobs: for binary in ${{ matrix.binaries }}; do build_args+=(--bin "$binary") done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" cargo build --target "$target" --release --timings "${build_args[@]}" - name: Upload Cargo timings @@ -1330,8 +1326,6 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} secrets: inherit argument-comment-lint-release-assets: diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 15e0e2c010..5d6d116049 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -501,7 +501,7 @@ debug = "none" strip = "symbols" [profile.release] -lto = "fat" +lto = "thin" split-debuginfo = "off" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. From d312a53e2a4419c339c890b79f02b56c19abac85 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 13:15:50 -0700 Subject: [PATCH 038/141] Remove response.processed websocket request (#26447) ## Why The Responses websocket client no longer needs to send a follow-up `response.processed` request after a turn response has already been recorded. Keeping that extra acknowledgement path adds feature-gated control flow and a second websocket request shape that no longer carries useful behavior. ## What Changed - Removed the `response.processed` websocket request type and sender. - Removed the `responses_websocket_response_processed` feature flag and schema entry. - Removed turn and remote-compaction plumbing that only tracked response IDs to send the acknowledgement. - Removed tests that existed solely to cover the deleted feature path. ## Validation - `just fix -p codex-core -p codex-api -p codex-features` --- codex-rs/codex-api/src/common.rs | 7 - .../src/endpoint/responses_websocket.rs | 35 ---- codex-rs/codex-api/src/lib.rs | 1 - codex-rs/core/config.schema.json | 8 +- codex-rs/core/src/client.rs | 17 +- codex-rs/core/src/compact_remote_v2.rs | 25 +-- codex-rs/core/src/session/turn.rs | 13 +- .../core/tests/suite/client_websockets.rs | 162 ------------------ codex-rs/features/src/lib.rs | 8 - codex-rs/features/src/tests.rs | 16 -- 10 files changed, 8 insertions(+), 284 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 91b251c41f..50ac2685b4 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -239,11 +239,6 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } -#[derive(Debug, Serialize)] -pub struct ResponseProcessedWsRequest { - pub response_id: String, -} - pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -272,8 +267,6 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), - #[serde(rename = "response.processed")] - ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index f0a0019817..a7366a9871 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,6 +1,5 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; -use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -206,40 +205,6 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } - #[instrument( - name = "responses_websocket.send_response_processed", - level = "info", - skip_all, - fields(transport = "responses_websocket", api.path = "responses") - )] - #[expect( - clippy::await_holding_invalid_type, - reason = "the guard serializes exclusive use of the websocket while sending a request frame" - )] - pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { - let request = - ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); - let request_body = serde_json::to_value(&request).map_err(|err| { - ApiError::Stream(format!("failed to encode websocket request: {err}")) - })?; - - let mut guard = self.stream.lock().await; - let Some(ws_stream) = guard.as_mut() else { - return Err(ApiError::Stream( - "websocket connection is closed".to_string(), - )); - }; - - send_websocket_request( - ws_stream, - request_body, - self.idle_timeout, - self.telemetry.as_ref(), - /*connection_reused*/ true, - ) - .await - } - #[instrument( name = "responses_websocket.stream_request", level = "info", diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 08176d8dec..8efebf076c 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -32,7 +32,6 @@ pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; -pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a584eae263..b8215e6fa5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -572,9 +572,6 @@ "request_rule": { "type": "boolean" }, - "responses_websocket_response_processed": { - "type": "boolean" - }, "responses_websockets": { "type": "boolean" }, @@ -4705,9 +4702,6 @@ "request_rule": { "type": "boolean" }, - "responses_websocket_response_processed": { - "type": "boolean" - }, "responses_websockets": { "type": "boolean" }, @@ -5231,4 +5225,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 124dfb18c0..2c6be6222a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -100,7 +100,6 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; -use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -967,18 +966,6 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } - pub(crate) async fn send_response_processed(&self, response_id: &str) { - let Some(connection) = self.websocket_session.connection.as_ref() else { - return; - }; - if let Err(err) = connection - .send_response_processed(response_id.to_string()) - .await - { - debug!("failed to send response.processed websocket request: {err}"); - } - } - #[allow(clippy::too_many_arguments)] /// Builds shared Responses API transport options and request-body options. /// @@ -1672,9 +1659,7 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option, } @@ -409,7 +400,7 @@ async fn collect_compaction_output( let mut output_item_count = 0usize; let mut compaction_count = 0usize; let mut compaction_output = None; - let mut completed_response_id = None; + let mut saw_completed = false; let mut completed_token_usage = None; while let Some(event) = stream.next().await { match event? { @@ -422,12 +413,8 @@ async fn collect_compaction_output( } } } - ResponseEvent::Completed { - response_id, - token_usage, - .. - } => { - completed_response_id = Some(response_id); + ResponseEvent::Completed { token_usage, .. } => { + saw_completed = true; completed_token_usage = token_usage; break; } @@ -435,12 +422,12 @@ async fn collect_compaction_output( } } - let Some(response_id) = completed_response_id else { + if !saw_completed { return Err(CodexErr::Stream( "remote compaction v2 stream closed before response.completed".to_string(), None, )); - }; + } if compaction_count != 1 { return Err(CodexErr::Fatal(format!( @@ -453,7 +440,6 @@ async fn collect_compaction_output( }; Ok(RemoteCompactionV2Output { compaction_output, - response_id, token_usage: completed_token_usage, }) } @@ -804,7 +790,6 @@ mod tests { .expect("compaction should be collected"); assert_eq!(output.compaction_output, compaction); - assert_eq!(output.response_id, "resp-compact"); assert_eq!( output.token_usage, Some(TokenUsage { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2765de76fb..03b1c4b1db 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1827,7 +1827,6 @@ async fn try_run_sampling_request( !sess.services.extensions.turn_item_contributors().is_empty(); let mut active_item_is_streaming_to_client = false; let receiving_span = trace_span!("receiving_stream"); - let mut completed_response_id: Option = None; let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, @@ -2071,9 +2070,9 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id, token_usage, end_turn, + .. } => { flush_assistant_text_segments_all( &sess, @@ -2089,7 +2088,6 @@ async fn try_run_sampling_request( if let Some(false) = end_turn { needs_follow_up = true; } - completed_response_id = Some(response_id); break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, @@ -2213,15 +2211,6 @@ async fn try_run_sampling_request( ) .await; - if sess - .features - .enabled(Feature::ResponsesWebsocketResponseProcessed) - && outcome.is_ok() - && let Some(response_id) = completed_response_id.as_deref() - { - client_session.send_response_processed(response_id).await; - } - drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; if should_emit_token_count { diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 41db88f1ad..9dee201d64 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -185,168 +185,6 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_sends_response_processed_when_feature_enabled() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - ]]) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ResponsesWebsocketResponseProcessed) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should send response.processed after processing"); - - let processed = server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 2) - .await; - assert_eq!( - processed.body_json(), - json!({ - "type": "response.processed", - "response_id": "resp-1", - }) - ); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 3); - assert_eq!( - connection[1].body_json()["type"].as_str(), - Some("response.create") - ); - - server.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_sends_response_processed_after_remote_compaction_v2() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - vec![ - json!({ - "type": "response.output_item.done", - "item": { - "type": "compaction", - "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", - } - }), - ev_completed("resp-compact"), - ], - vec![], - ]]) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::RemoteCompactionV2) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ResponsesWebsocketResponseProcessed) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should send response.processed after processing"); - - test.codex - .submit(Op::Compact) - .await - .expect("compact submission should succeed"); - wait_for_event(&test.codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - - let compact_processed = server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 4) - .await; - assert_eq!( - compact_processed.body_json(), - json!({ - "type": "response.processed", - "response_id": "resp-compact", - }) - ); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 5); - assert_eq!( - connection[3].body_json()["type"].as_str(), - Some("response.create") - ); - - server.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_omits_response_processed_without_feature() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - ]]) - .await; - let mut builder = test_codex(); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should complete without response.processed"); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 2); - - server.shutdown().await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index a1f5a25c00..3ba973b804 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -207,8 +207,6 @@ pub enum Feature { RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, - /// Send `response.processed` over Responses API websockets after a turn response is recorded. - ResponsesWebsocketResponseProcessed, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Enable workspace dependency support. @@ -1228,12 +1226,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, - FeatureSpec { - id: Feature::ResponsesWebsocketResponseProcessed, - key: "responses_websocket_response_processed", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 5d7087e90b..aa5e0174ba 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -146,22 +146,6 @@ fn remote_compaction_v2_is_under_development() { ); } -#[test] -fn responses_websocket_response_processed_is_under_development() { - assert_eq!( - Feature::ResponsesWebsocketResponseProcessed.stage(), - Stage::UnderDevelopment - ); - assert_eq!( - Feature::ResponsesWebsocketResponseProcessed.default_enabled(), - false - ); - assert_eq!( - feature_for_key("responses_websocket_response_processed"), - Some(Feature::ResponsesWebsocketResponseProcessed) - ); -} - #[test] fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { assert_eq!( From 4a70e0ac1b39c6da6863069bebc82ce3513bc8b5 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 4 Jun 2026 13:35:31 -0700 Subject: [PATCH 039/141] Cleanup experimentalFeature/enablement/set (#26312) ## Why `experimentalFeature/enablement/set` still allowed several keys that no longer need to be managed through this API. Keeping those keys also preserved corresponding special-case logic, including refreshing the apps list when the `apps` key was enabled. The endpoint also rejected an entire request when any key was invalid or unsupported. That makes clients brittle when they send a mix of current and stale keys, even when the valid entries can still be applied safely. ## What changed - remove the feature keys that no longer need to be supported by `experimentalFeature/enablement/set` - remove the corresponding apps-list refresh path and its auth/config plumbing - ignore and warn on invalid or unsupported keys while still applying valid keys from the same request - update the app-server documentation and integration coverage for the reduced key set and partial-acceptance behavior ## Test plan - `just test -p codex-app-server experimental_feature_enablement_set` (6 passed) - `just test -p codex-app-server` exercised the changed tests successfully; unrelated sandbox-dependent and watcher/timing tests failed locally --- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/message_processor.rs | 3 +- .../request_processors/config_processor.rs | 114 ++---------------- .../app-server/tests/suite/v2/app_list.rs | 104 ---------------- .../suite/v2/experimental_feature_list.rs | 103 ++++++++-------- 5 files changed, 63 insertions(+), 263 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dc02ff8c04..1f5d01a429 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -192,7 +192,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 84f22809ee..d89f0b0645 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -455,14 +455,13 @@ impl MessageProcessor { .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( &config.plugins_config_input(), - auth_manager.clone(), + auth_manager, Some(on_effective_plugins_changed), ); } let config_processor = ConfigRequestProcessor::new( outgoing.clone(), config_manager.clone(), - auth_manager, thread_manager.clone(), analytics_events_client, ); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 3571026193..3753550925 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -7,7 +7,6 @@ use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use codex_analytics::AnalyticsEventsClient; -use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -29,9 +28,7 @@ use codex_app_server_protocol::NetworkDomainPermission; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; -use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; -use codex_chatgpt::connectors; use codex_config::ConfigRequirementsToml; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig as CoreHookHandlerConfig; @@ -42,7 +39,6 @@ use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; -use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_plugin::PluginId; use codex_protocol::config_types::WebSearchMode; @@ -50,21 +46,18 @@ use serde_json::json; use std::path::PathBuf; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ - "apps", + "auth_elicitation", "memories", "mentions_v2", - "plugins", "remote_control", "remote_plugin", "tool_suggest", - "tool_call_mcp_elicitation", ]; #[derive(Clone)] pub(crate) struct ConfigRequestProcessor { outgoing: Arc, config_manager: ConfigManager, - auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, } @@ -73,14 +66,12 @@ impl ConfigRequestProcessor { pub(crate) fn new( outgoing: Arc, config_manager: ConfigManager, - auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, ) -> Self { Self { outgoing, config_manager, - auth_manager, thread_manager, analytics_events_client, } @@ -151,7 +142,6 @@ impl ConfigRequestProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureEnablementSetParams, ) -> Result, JSONRPCErrorError> { - let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); let response = self .handle_config_mutation_result(self.set_experimental_feature_enablement(params).await) .await?; @@ -161,10 +151,6 @@ impl ConfigRequestProcessor { ClientResponsePayload::ExperimentalFeatureEnablementSet(response), ) .await; - if should_refresh_apps_list { - self.refresh_apps_list_after_experimental_feature_enablement_set() - .await; - } Ok(None) } @@ -195,71 +181,6 @@ impl ConfigRequestProcessor { Ok(response) } - async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - tracing::warn!( - "failed to load config for apps list refresh after experimental feature enablement: {}", - error.message - ); - return; - } - }; - let auth = self.auth_manager.auth().await; - if !config.features.apps_enabled_for_auth( - auth.as_ref() - .is_some_and(codex_login::CodexAuth::uses_codex_backend), - ) { - return; - } - - let outgoing = Arc::clone(&self.outgoing); - let environment_manager = self.thread_manager.environment_manager(); - tokio::spawn(async move { - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - /*force_refetch*/ true, - Arc::clone(&environment_manager), - ), - ); - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh directory apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - let accessible_connectors = match accessible_connectors_result { - Ok(status) => status.connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - - let data = connectors::with_app_enabled_state( - connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ), - &config, - ); - outgoing - .send_server_notification(ServerNotification::AppListUpdated( - AppListUpdatedNotification { data }, - )) - .await; - }); - } - async fn load_latest_config( &self, fallback_cwd: Option, @@ -317,28 +238,19 @@ impl ConfigRequestProcessor { &self, params: ExperimentalFeatureEnablementSetParams, ) -> Result { - let ExperimentalFeatureEnablementSetParams { enablement } = params; - for key in enablement.keys() { - if canonical_feature_for_key(key).is_some() { - if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { - continue; - } - - return Err(invalid_request(format!( - "unsupported feature enablement `{key}`: currently supported features are {}", - SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") - ))); + let ExperimentalFeatureEnablementSetParams { mut enablement } = params; + let mut invalid_keys = Vec::new(); + enablement.retain(|key, _| { + let valid = canonical_feature_for_key(key).is_some() + && SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()); + if !valid { + invalid_keys.push(key.clone()); } - - let message = if let Some(feature) = feature_for_key(key) { - format!( - "invalid feature enablement `{key}`: use canonical feature key `{}`", - feature.key() - ) - } else { - format!("invalid feature enablement `{key}`") - }; - return Err(invalid_request(message)); + valid + }); + if !invalid_keys.is_empty() { + let invalid_keys = invalid_keys.join(", "); + tracing::warn!("ignoring invalid experimental feature enablement keys: {invalid_keys}"); } if enablement.is_empty() { diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index ea7ff8fa4b..2177106f8f 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -28,7 +27,6 @@ use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -1335,108 +1333,6 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu Ok(()) } -#[tokio::test] -async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> { - let initial_connectors = vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v1".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]; - let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( - initial_connectors, - Vec::new(), - Duration::ZERO, - Duration::ZERO, - ) - .await?; - - let codex_home = TempDir::new()?; - write_connectors_config(codex_home.path(), &server_url)?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-enable-refresh") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let disable_request = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("apps".to_string(), false)]), - }) - .await?; - let _disable_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(disable_request)), - ) - .await??; - - server_control.set_connectors(vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v2".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]); - server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]); - - let enable_request = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("apps".to_string(), true)]), - }) - .await?; - let _enable_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(enable_request)), - ) - .await??; - - let update = read_app_list_updated_notification(&mut mcp).await?; - assert_eq!( - update.data, - vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v2".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }] - ); - - server_handle.abort(); - Ok(()) -} - async fn read_app_list_updated_notification( mcp: &mut TestAppServer, ) -> Result { 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 60a0f94839..797736b7af 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 @@ -267,13 +267,15 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let actual = - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("auth_elicitation".to_string(), true)]), + ) + .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("apps".to_string(), true)]), + enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), } ); @@ -284,7 +286,7 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); } @@ -333,16 +335,18 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("mentions_v2".to_string(), true)]), + ) + .await?; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ + ("auth_elicitation".to_string(), true), ("memories".to_string(), true), - ("plugins".to_string(), true), ("remote_plugin".to_string(), true), - ("tool_suggest".to_string(), true), - ("tool_call_mcp_elicitation".to_string(), false), + ("tool_suggest".to_string(), false), ]), ) .await?; @@ -351,11 +355,10 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([ + ("auth_elicitation".to_string(), true), ("memories".to_string(), true), - ("plugins".to_string(), true), ("remote_plugin".to_string(), true), - ("tool_suggest".to_string(), true), - ("tool_call_mcp_elicitation".to_string(), false), + ("tool_suggest".to_string(), false), ]), } ); @@ -366,7 +369,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("mentions_v2")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); assert_eq!( @@ -376,13 +386,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("memories")), Some(&json!(true)) ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("plugins")), - Some(&json!(true)) - ); assert_eq!( config .additional @@ -395,13 +398,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .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)) ); @@ -432,8 +428,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("mentions_v2".to_string(), true)]), + ) + .await?; let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; assert_eq!( @@ -449,7 +448,7 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("mentions_v2")), Some(&json!(true)) ); @@ -457,36 +456,30 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> } #[tokio::test] -async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { +async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("personality".to_string(), true)]), - }) - .await?; - let JSONRPCError { error, .. } = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([ + ("apps".to_string(), false), + ("auth_elicitation".to_string(), true), + ("connectors".to_string(), false), + ("personality".to_string(), false), + ("plugins".to_string(), false), + ("tool_call_mcp_elicitation".to_string(), false), + ("unknown_feature".to_string(), true), + ]), ) - .await??; + .await?; - assert_eq!(error.code, -32600); - assert!( - error - .message - .contains("unsupported feature enablement `personality`"), - "{}", - error.message - ); - assert!( - error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" - ), - "{}", - error.message + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), + } ); Ok(()) From 8ac304c2997529d3b6fe4be4ba032e0f2dac16af Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Jun 2026 13:36:24 -0700 Subject: [PATCH 040/141] [codex] Support model-defined reasoning efforts (#26444) ## Summary - accept non-empty model-defined reasoning effort values while preserving built-in effort behavior - propagate the non-Copy effort type through core, app-server, TUI, telemetry, and persistence call sites - preserve string wire encoding and expose an open-string schema for clients - update model selection and shortcut behavior for model-advertised effort values ## Root cause `ReasoningEffort` gained a string-backed custom variant, so it could no longer implement `Copy` or rely on derived closed-enum serialization. Existing consumers still moved effort values from shared references and assumed a fixed built-in value set. ## Validation - `just fmt` - Local tests and compilation were not run per request; relying on CI. --- .../schema/json/ClientRequest.json | 11 +- .../schema/json/ServerNotification.json | 11 +- .../codex_app_server_protocol.schemas.json | 11 +- .../codex_app_server_protocol.v2.schemas.json | 11 +- .../schema/json/v2/ConfigReadResponse.json | 11 +- .../json/v2/ItemCompletedNotification.json | 11 +- .../json/v2/ItemStartedNotification.json | 11 +- .../schema/json/v2/ModelListResponse.json | 11 +- .../schema/json/v2/ReviewStartResponse.json | 11 +- .../schema/json/v2/ThreadForkResponse.json | 11 +- .../schema/json/v2/ThreadListResponse.json | 11 +- .../json/v2/ThreadMetadataUpdateResponse.json | 11 +- .../schema/json/v2/ThreadReadResponse.json | 11 +- .../schema/json/v2/ThreadResumeResponse.json | 11 +- .../json/v2/ThreadRollbackResponse.json | 11 +- .../v2/ThreadSettingsUpdatedNotification.json | 11 +- .../schema/json/v2/ThreadStartResponse.json | 11 +- .../json/v2/ThreadStartedNotification.json | 11 +- .../json/v2/ThreadUnarchiveResponse.json | 11 +- .../json/v2/TurnCompletedNotification.json | 11 +- .../schema/json/v2/TurnStartParams.json | 11 +- .../schema/json/v2/TurnStartResponse.json | 11 +- .../json/v2/TurnStartedNotification.json | 11 +- .../schema/typescript/ReasoningEffort.ts | 2 +- .../src/protocol/thread_history.rs | 4 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/models.rs | 4 +- .../request_processors/thread_processor.rs | 2 +- .../src/request_processors/thread_summary.rs | 2 +- .../src/request_processors/turn_processor.rs | 2 +- .../app-server/tests/common/models_cache.rs | 2 +- .../app-server/tests/suite/v2/model_list.rs | 8 +- codex-rs/core/config.schema.json | 11 +- codex-rs/core/src/client.rs | 8 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 2 +- codex-rs/core/src/compact_remote_v2.rs | 2 +- codex-rs/core/src/config/edit.rs | 2 +- codex-rs/core/src/guardian/review.rs | 7 +- codex-rs/core/src/guardian/review_session.rs | 11 +- codex-rs/core/src/session/config_lock.rs | 2 +- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/review.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 16 +- codex-rs/core/src/session/tests.rs | 20 +- codex-rs/core/src/session/turn.rs | 4 +- codex-rs/core/src/session/turn_context.rs | 22 +- codex-rs/core/src/session_startup_prewarm.rs | 2 +- .../src/tools/handlers/multi_agents/spawn.rs | 12 +- .../src/tools/handlers/multi_agents_common.rs | 11 +- .../src/tools/handlers/multi_agents_spec.rs | 14 +- .../tools/handlers/multi_agents_spec_tests.rs | 21 ++ .../src/tools/handlers/multi_agents_tests.rs | 4 +- .../tools/handlers/multi_agents_v2/spawn.rs | 12 +- codex-rs/core/tests/responses_headers.rs | 6 +- codex-rs/core/tests/suite/client.rs | 4 +- .../core/tests/suite/client_websockets.rs | 24 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- .../core/tests/suite/model_visible_layout.rs | 10 +- codex-rs/core/tests/suite/personality.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 6 +- codex-rs/core/tests/suite/remote_models.rs | 11 +- codex-rs/core/tests/suite/resume_warning.rs | 2 +- .../tests/suite/safety_check_downgrade.rs | 2 +- codex-rs/docs/codex_mcp_interface.md | 2 +- .../src/event_processor_with_human_output.rs | 3 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/otel/src/events/session_telemetry.rs | 2 +- codex-rs/protocol/Cargo.toml | 3 +- codex-rs/protocol/src/config_types.rs | 9 +- codex-rs/protocol/src/openai_models.rs | 226 ++++++++++++++---- codex-rs/state/src/extract.rs | 2 +- codex-rs/state/src/model/thread_metadata.rs | 4 +- codex-rs/thread-store/src/in_memory.rs | 2 +- .../thread-store/src/thread_metadata_sync.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 16 +- codex-rs/tui/src/app/event_dispatch.rs | 16 +- codex-rs/tui/src/app/startup_prompts.rs | 8 +- codex-rs/tui/src/app/tests.rs | 4 +- codex-rs/tui/src/app/thread_routing.rs | 2 +- codex-rs/tui/src/app/thread_settings.rs | 6 +- codex-rs/tui/src/app_server_session.rs | 9 +- codex-rs/tui/src/chatwidget/input_flow.rs | 2 +- codex-rs/tui/src/chatwidget/model_popups.rs | 159 ++++++------ codex-rs/tui/src/chatwidget/rate_limits.rs | 6 +- .../tui/src/chatwidget/reasoning_shortcuts.rs | 123 +++++++--- codex-rs/tui/src/chatwidget/session_flow.rs | 4 +- codex-rs/tui/src/chatwidget/settings.rs | 18 +- ...ests__model_reasoning_selection_popup.snap | 1 + .../tui/src/chatwidget/status_controls.rs | 14 +- .../tui/src/chatwidget/status_surfaces.rs | 9 +- .../chatwidget/tests/popups_and_settings.rs | 43 +++- codex-rs/tui/src/history_cell/session.rs | 17 +- codex-rs/tui/src/multi_agents.rs | 2 +- codex-rs/tui/src/status/card.rs | 2 +- .../sandbox-summary/src/config_summary.rs | 3 +- 97 files changed, 685 insertions(+), 581 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index dc5dd6ad60..861ae1dcbd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1971,15 +1971,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningItemContent": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index faf5a96879..16656e3a55 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2777,15 +2777,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6aa4c5f35e..81617aa93c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13525,15 +13525,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6d859ee5f3..336350bcec 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10047,15 +10047,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 09cf897898..e311090f2c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -667,15 +667,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 8ba25f9a07..4b1d1dddbf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -462,15 +462,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 70fc43f7b8..67e493a4da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -462,15 +462,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index 32ec2e7679..ce6c976d34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -185,15 +185,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index a644ce8c4e..0e3e704bf2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 0d3a3fe8db..0dbb7ef004 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index a500c21587..903553afaa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6e08edb962..93f44759bf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index eed031d1e4..a78d1837c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 3a9e51f2a7..288c3e6c8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 9bc687fbfb..2ef7959a8b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 42a76ae364..fbcaee3ee8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -124,15 +124,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 8640c4e9ed..1a182e5d69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index f7830e221e..66ed134c83 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 9a634c1110..d331fa9bf7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 6c64eb3275..dd5ebbf6ee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index f997976aca..070944a28b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -154,15 +154,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 5fe9754576..cc4bce10ff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 1db1497218..10b43d4354 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts b/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts index c0798f43a3..d40f5bd657 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts @@ -5,4 +5,4 @@ /** * See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning */ -export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ReasoningEffort = string; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index aa65f9ab9d..fdc2af15d3 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -621,7 +621,7 @@ impl ThreadHistoryBuilder { receiver_thread_ids: Vec::new(), prompt: Some(payload.prompt.clone()), model: Some(payload.model.clone()), - reasoning_effort: Some(payload.reasoning_effort), + reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states: HashMap::new(), }; self.upsert_item_in_current_turn(item); @@ -656,7 +656,7 @@ impl ThreadHistoryBuilder { receiver_thread_ids, prompt: Some(payload.prompt.clone()), model: Some(payload.model.clone()), - reasoning_effort: Some(payload.reasoning_effort), + reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states, }); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1f5d01a429..a789c61af0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index e53f13df84..7d460d5a97 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset( efforts: Vec, ) -> Vec { efforts - .iter() + .into_iter() .map(|preset| ReasoningEffortOption { reasoning_effort: preset.effort, - description: preset.description.to_string(), + description: preset.description, }) .collect() } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 6b4df61aa1..efff8e7cd8 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -158,7 +158,7 @@ fn merge_persisted_resume_metadata( typesafe_overrides.model = persisted_metadata.model.clone(); typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); - if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { + if let Some(reasoning_effort) = persisted_metadata.reasoning_effort.as_ref() { request_overrides.get_or_insert_with(HashMap::new).insert( "model_reasoning_effort".to_string(), serde_json::Value::String(reasoning_effort.to_string()), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index a57f2fcf79..f467074828 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -203,7 +203,7 @@ pub(crate) fn thread_settings_from_config_snapshot( model: config_snapshot.model.clone(), model_provider: config_snapshot.model_provider_id.clone(), service_tier: config_snapshot.service_tier.clone(), - effort: config_snapshot.reasoning_effort, + effort: config_snapshot.reasoning_effort.clone(), summary: config_snapshot.reasoning_summary, collaboration_mode: config_snapshot.collaboration_mode.clone(), personality: config_snapshot.personality, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 25beee3cdb..8ab6cf7731 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -629,7 +629,7 @@ impl TurnRequestProcessor { profile_workspace_roots: profile_workspace_roots.clone(), windows_sandbox_level: None, model: model.clone(), - effort, + effort: effort.clone(), summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 666f6ed7cd..127c14bbb4 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -18,7 +18,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { slug: preset.id.clone(), display_name: preset.display_name.clone(), description: Some(preset.description.clone()), - default_reasoning_level: Some(preset.default_reasoning_effort), + default_reasoning_level: Some(preset.default_reasoning_effort.clone()), supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), shell_type: ConfigShellToolType::ShellCommand, visibility: if preset.show_in_picker { diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 778577f857..eba04b54bd 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -48,11 +48,11 @@ fn model_from_preset(preset: &ModelPreset) -> Model { .supported_reasoning_efforts .iter() .map(|preset| ReasoningEffortOption { - reasoning_effort: preset.effort, + reasoning_effort: preset.effort.clone(), description: preset.description.clone(), }) .collect(), - default_reasoning_effort: preset.default_reasoning_effort, + default_reasoning_effort: preset.default_reasoning_effort.clone(), input_modalities: preset.input_modalities.clone(), // `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not // preserve personality placeholders in base instructions, so app-server list results from @@ -164,10 +164,10 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "slug": "chatgpt-remote-only", "display_name": "ChatGPT Remote Only", "description": "Remote-only model for app-server model/list coverage", - "default_reasoning_level": "medium", + "default_reasoning_level": "max", "supported_reasoning_levels": [ {"effort": "low", "description": "low"}, - {"effort": "medium", "description": "medium"} + {"effort": "max", "description": "Maximum"} ], "shell_type": "shell_command", "visibility": "list", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b8215e6fa5..f5a97b142a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2503,15 +2503,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 2c6be6222a..9c133d85e4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -721,7 +721,7 @@ impl ModelClient { ) -> Option { if model_info.supports_reasoning_summaries { Some(Reasoning { - effort: effort.or(model_info.default_reasoning_level), + effort: effort.or_else(|| model_info.default_reasoning_level.clone()), summary: if summary == ReasoningSummaryConfig::None { None } else { @@ -1267,7 +1267,7 @@ impl ModelClientSession { &client_setup.api_provider, prompt, model_info, - effort, + effort.clone(), summary, service_tier.clone(), )?; @@ -1376,7 +1376,7 @@ impl ModelClientSession { &client_setup.api_provider, prompt, model_info, - effort, + effort.clone(), summary, service_tier.clone(), )?; @@ -1595,7 +1595,7 @@ impl ModelClientSession { prompt, model_info, session_telemetry, - effort, + effort.clone(), summary, service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4dc4c34e99..18b5838292 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -573,7 +573,7 @@ async fn drain_to_completed( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 444b1c3a69..a480c1eba8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -242,7 +242,7 @@ async fn run_remote_compact_task_inner_impl( &prompt, &turn_context.model_info, CompactConversationRequestSettings { - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: turn_context.reasoning_summary, service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { None diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 03a31e7230..5f585862dd 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -339,7 +339,7 @@ async fn run_remote_compaction_request_v2( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 862660ba2e..2d7a41323b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -225,7 +225,7 @@ impl ConfigDocument { ); mutated |= self.write_optional_value( &["model_reasoning_effort"], - effort.map(|effort| value(effort.to_string())), + effort.as_ref().map(|effort| value(effort.to_string())), ); mutated }), diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 06d1a78931..934e68042f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -702,7 +702,7 @@ pub(super) async fn run_guardian_review_session( .supported_reasoning_efforts .iter() .any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), ); (review_model_id.to_string(), reasoning_effort) } else { @@ -712,7 +712,8 @@ pub(super) async fn run_guardian_review_session( .iter() .any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low), turn.reasoning_effort - .or(turn.model_info.default_reasoning_level), + .clone() + .or_else(|| turn.model_info.default_reasoning_level.clone()), ); ( model_override @@ -725,7 +726,7 @@ pub(super) async fn run_guardian_review_session( turn.config.as_ref(), live_network_config.clone(), guardian_model.as_str(), - guardian_reasoning_effort, + guardian_reasoning_effort.clone(), ); let guardian_config = match guardian_config { Ok(config) => config, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 0de607de98..d81f84590b 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -167,7 +167,7 @@ impl GuardianReviewSessionReuseKey { model_context_window: spawn_config.model_context_window, model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit, model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope, - model_reasoning_effort: spawn_config.model_reasoning_effort, + model_reasoning_effort: spawn_config.model_reasoning_effort.clone(), model_reasoning_summary: spawn_config.model_reasoning_summary, permissions: spawn_config.permissions.clone(), developer_instructions: spawn_config.developer_instructions.clone(), @@ -658,7 +658,8 @@ async fn run_review_on_session( let guardian_reasoning_effort = if model_info.supports_reasoning_summaries { params .reasoning_effort - .or(model_info.default_reasoning_level) + .clone() + .or_else(|| model_info.default_reasoning_level.clone()) } else { None }; @@ -742,7 +743,7 @@ async fn run_review_on_session( mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: params.model.clone(), - reasoning_effort: params.reasoning_effort, + reasoning_effort: params.reasoning_effort.clone(), developer_instructions: None, }, }), @@ -1107,7 +1108,7 @@ mod tests { async fn test_review_params() -> GuardianReviewSessionParams { let (session, turn) = crate::session::tests::make_session_and_context().await; let model = turn.model_info.slug.clone(); - let reasoning_effort = turn.reasoning_effort; + let reasoning_effort = turn.reasoning_effort.clone(); let reasoning_summary = turn.reasoning_summary; let personality = turn.personality; #[allow(deprecated)] @@ -1116,7 +1117,7 @@ mod tests { turn.config.as_ref(), /*live_network_config*/ None, model.as_str(), - reasoning_effort, + reasoning_effort.clone(), ) .expect("guardian config"); diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 5d608ec900..c081a2aee6 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -129,7 +129,7 @@ fn save_config_resolved_fields( ) -> anyhow::Result<()> { lock_config.web_search = Some(config.web_search_mode.value()); lock_config.model_provider = Some(config.model_provider_id.clone()); - lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; + lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort.clone(); lock_config.model_verbosity = config.model_verbosity; lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); lock_config.include_apps_instructions = Some(config.include_apps_instructions); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c0bf9629b7..9aeca1991b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -579,7 +579,7 @@ impl Codex { mode: ModeKind::Default, settings: Settings { model: model.clone(), - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index de864ebb08..ff4c77aab9 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -74,7 +74,7 @@ pub(super) async fn spawn_review_thread( let auth_manager_for_context = auth_manager.clone(); let provider_for_context = provider.clone(); let session_telemetry_for_context = session_telemetry.clone(); - let reasoning_effort = per_turn_config.model_reasoning_effort; + let reasoning_effort = per_turn_config.model_reasoning_effort.clone(); let reasoning_summary = per_turn_config .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 0ca54e55e0..9ee70dada3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -74,7 +74,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -113,7 +113,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -963,7 +963,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1042,7 +1042,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, })) .expect("serialize expected reference context item") @@ -1071,7 +1071,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1190,7 +1190,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; @@ -1308,7 +1308,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1467,7 +1467,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 93dc1e109f..c5e37fca0e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2559,7 +2559,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -3146,7 +3146,7 @@ async fn set_rate_limits_retains_previous_credits() { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3254,7 +3254,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3785,7 +3785,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -4614,7 +4614,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -4716,7 +4716,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -4959,7 +4959,7 @@ async fn make_session_with_config_and_rx( mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -5064,7 +5064,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -5999,7 +5999,7 @@ async fn user_turn_updates_approvals_reviewer() { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: turn_context.model_info.slug.clone(), - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -6806,7 +6806,7 @@ where let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 03b1c4b1db..4a9316496c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -698,7 +698,7 @@ async fn track_turn_resolved_config_analytics( permission_profile: turn_context.permission_profile(), #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), - reasoning_effort: turn_context.reasoning_effort, + reasoning_effort: turn_context.reasoning_effort.clone(), reasoning_summary: Some(turn_context.reasoning_summary), service_tier: turn_context .config @@ -1799,7 +1799,7 @@ async fn try_run_sampling_request( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e62e447155..66a32f2aa6 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -136,7 +136,8 @@ impl TurnContext { pub(crate) fn effective_reasoning_effort(&self) -> Option { if self.model_info.supports_reasoning_summaries { self.reasoning_effort - .or(self.model_info.default_reasoning_level) + .clone() + .or_else(|| self.model_info.default_reasoning_level.clone()) } else { None } @@ -196,28 +197,29 @@ impl TurnContext { let supported_reasoning_levels = model_info .supported_reasoning_levels .iter() - .map(|preset| preset.effort) + .map(|preset| preset.effort.clone()) .collect::>(); - let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort { + let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort.clone() + { if supported_reasoning_levels.contains(¤t_reasoning_effort) { Some(current_reasoning_effort) } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) - .copied() - .or(model_info.default_reasoning_level) + .cloned() + .or_else(|| model_info.default_reasoning_level.clone()) } } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) - .copied() - .or(model_info.default_reasoning_level) + .cloned() + .or_else(|| model_info.default_reasoning_level.clone()) }; - config.model_reasoning_effort = reasoning_effort; + config.model_reasoning_effort = reasoning_effort.clone(); let collaboration_mode = self.collaboration_mode.with_updates( Some(model.clone()), - Some(reasoning_effort), + Some(reasoning_effort.clone()), /*developer_instructions*/ None, ); let features = self.features.clone(); @@ -363,7 +365,7 @@ impl TurnContext { collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), realtime_active: Some(self.realtime_active), - effort: self.reasoning_effort, + effort: self.reasoning_effort.clone(), summary: ReasoningSummaryConfig::Auto, } } diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index d620648179..ba2cc42f29 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -271,7 +271,7 @@ async fn schedule_startup_prewarm_inner( &startup_prompt, &startup_turn_context.model_info, &startup_turn_context.session_telemetry, - startup_turn_context.reasoning_effort, + startup_turn_context.reasoning_effort.clone(), startup_turn_context.reasoning_summary, startup_turn_context.config.service_tier.clone(), startup_turn_metadata_header.as_deref(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 004dc973b7..a50d17f34a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -83,7 +83,7 @@ async fn handle_spawn_agent( sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), } .into(), ) @@ -94,14 +94,18 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if args.fork_context { - reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort.clone(), + )?; } else { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), &mut config, args.model.as_deref(), - args.reasoning_effort, + args.reasoning_effort.clone(), ) .await?; apply_role_to_config(&mut config, role_name) @@ -174,7 +178,7 @@ async fn handle_spawn_agent( .unwrap_or_else(|| args.model.clone().unwrap_or_default()); let effective_reasoning_effort = agent_snapshot .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) + .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); session diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 8a4edbff35..8563ed09cb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -224,7 +224,8 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result Result<(), FunctionCallError> { if supported_reasoning_levels .iter() - .any(|preset| preset.effort == requested_reasoning_effort) + .any(|preset| &preset.effort == requested_reasoning_effort) { return Ok(()); } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index e80f63e35d..a15464d9df 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -17,6 +17,7 @@ const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Service tier override for the new agent. Omit unless explicitly requested."; const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; +const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -762,13 +763,20 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String { let model_descriptions = visible_models .into_iter() .map(|model| { - let default_reasoning_effort = model.default_reasoning_effort; + let default_reasoning_effort = &model.default_reasoning_effort; let efforts = model .supported_reasoning_efforts .iter() .map(|preset| { - let effort = preset.effort; - if effort == default_reasoning_effort { + let effort = preset.effort.as_str(); + let effort = match effort + .char_indices() + .nth(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) + { + Some((index, _)) => &effort[..index], + None => effort, + }; + if &preset.effort == default_reasoning_effort { format!("{effort} (default)") } else { effort.to_string() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 5f4a66a75c..95be504011 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -186,6 +186,27 @@ fn spawn_agent_tool_caps_visible_model_summaries() { assert!(!description.contains("`sixth-model`")); } +#[test] +fn spawn_agent_tool_caps_reasoning_effort_value_length() { + let mut model = model_preset("visible", /*show_in_picker*/ true); + let custom_effort = ReasoningEffort::Custom( + "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION + 1), + ); + model.default_reasoning_effort = custom_effort.clone(); + model.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: custom_effort, + description: "Model-defined".to_string(), + }]; + + assert_eq!( + spawn_agent_models_description(&[model]), + format!( + "Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.", + "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) + ) + ); +} + #[test] fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { 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 6eec6fa2ed..3edbdf19aa 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4374,7 +4374,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); @@ -4431,7 +4431,7 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.base_instructions = None; expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d0fb6c515e..7d5b469a45 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -75,7 +75,7 @@ async fn handle_spawn_agent( sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), } .into(), ) @@ -86,14 +86,18 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort.clone(), + )?; } else { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), &mut config, args.model.as_deref(), - args.reasoning_effort, + args.reasoning_effort.clone(), ) .await?; apply_role_to_config(&mut config, role_name) @@ -188,7 +192,7 @@ async fn handle_spawn_agent( .unwrap_or_else(|| args.model.clone().unwrap_or_default()); let effective_reasoning_effort = agent_snapshot .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) + .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); session diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index eda48a2c0e..75649fa4bc 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -74,7 +74,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -202,7 +202,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -318,7 +318,7 @@ async fn responses_respects_model_info_overrides_from_config() { config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = config.model.clone().expect("model configured"); let config = Arc::new(config); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f430a5c1eb..ad8ff17cdc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -876,7 +876,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -2332,7 +2332,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 9dee201d64..b28f820569 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -343,7 +343,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -397,7 +397,7 @@ async fn responses_websocket_request_prewarm_traces_logical_request() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -442,7 +442,7 @@ async fn responses_websocket_request_prewarm_traces_logical_request() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -534,7 +534,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -573,7 +573,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -585,7 +585,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -639,7 +639,7 @@ async fn responses_websocket_prewarm_uses_v2_when_provider_supports_websockets() &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -988,7 +988,7 @@ async fn responses_websocket_emits_reasoning_included_event() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1062,7 +1062,7 @@ async fn responses_websocket_emits_rate_limit_events() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1718,7 +1718,7 @@ async fn responses_websocket_v2_after_error_uses_full_create_without_previous_re &prompt_two, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1806,7 +1806,7 @@ async fn responses_websocket_v2_surfaces_terminal_error_without_close_handshake( &prompt_two, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -2073,7 +2073,7 @@ async fn stream_until_complete_with_request_metadata( prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, service_tier.map(|service_tier| service_tier.request_value().to_string()), turn_metadata_header, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b76a3522ec..111ce50436 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -58,7 +58,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model, - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index e02e1ffaef..27d92d2774 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -135,7 +135,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -172,7 +172,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -261,7 +261,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -295,7 +295,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -422,7 +422,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: resumed.session_configured.model.clone(), - reasoning_effort: resumed.config.model_reasoning_effort, + reasoning_effort: resumed.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 231a6d826f..cd264c0734 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -79,7 +79,7 @@ fn read_only_text_turn_with_personality( mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model, - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 2a42bd0a16..ea646a4dbc 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -870,7 +870,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = &config.legacy_sandbox_policy(); let default_model = session_configured.model; - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let default_summary = config.model_reasoning_summary; codex @@ -892,7 +892,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: default_model.clone(), - reasoning_effort: default_effort, + reasoning_effort: default_effort.clone(), developer_instructions: None, }, }), @@ -1013,7 +1013,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = &config.legacy_sandbox_policy(); let default_model = session_configured.model; - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let default_summary = config.model_reasoning_summary; codex diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index fd6e01c075..608558d365 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -318,7 +318,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<()> { +async fn remote_models_long_model_slug_is_sent_with_custom_reasoning() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -331,15 +331,16 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( /*priority*/ 1_000, TruncationPolicyConfig::bytes(/*limit*/ 10_000), ); - remote_model.default_reasoning_level = Some(ReasoningEffort::High); + let custom_reasoning_effort = ReasoningEffort::Custom("max".to_string()); + remote_model.default_reasoning_level = Some(custom_reasoning_effort.clone()); remote_model.supported_reasoning_levels = vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }, ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: ReasoningEffort::High.to_string(), + effort: custom_reasoning_effort.clone(), + description: custom_reasoning_effort.to_string(), }, ]; remote_model.supports_reasoning_summaries = true; @@ -393,7 +394,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( .and_then(|reasoning| reasoning.get("summary")) .and_then(|value| value.as_str()); assert_eq!(body["model"].as_str(), Some(requested_model)); - assert_eq!(reasoning_effort, Some("high")); + assert_eq!(reasoning_effort, Some("max")); assert_eq!(reasoning_summary, Some("detailed")); Ok(()) diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 54b046e66c..422489a86a 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -41,7 +41,7 @@ fn resume_history( collaboration_mode: None, multi_agent_version: None, realtime_active: None, - effort: config.model_reasoning_effort, + effort: config.model_reasoning_effort.clone(), summary: config .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 3d4cb93b9c..532f4821d9 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -55,7 +55,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: REQUESTED_MODEL.to_string(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index bf2def2407..e3eb814f36 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -68,7 +68,7 @@ Each response yields: - `data` - ordered list of models. A model includes: - `id`, `model`, `displayName`, `description` - `supportedReasoningEfforts` - array of objects with: - - `reasoningEffort` - one of `none|minimal|low|medium|high|xhigh` + - `reasoningEffort` - a string value advertised by the model; common values are `none|minimal|low|medium|high|xhigh` - `description` - human-friendly label for the effort - `defaultReasoningEffort` - suggested effort for the UI - `inputModalities` - accepted input types for the model diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index c3190b8740..00a9f957f1 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -446,7 +446,8 @@ fn config_summary_entries( "reasoning effort", config .model_reasoning_effort - .map(|effort| effort.to_string()) + .as_ref() + .map(std::string::ToString::to_string) .unwrap_or_else(|| "none".to_string()), )); entries.push(( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 406197d720..797e168dc5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -694,7 +694,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.permissions.approval_policy.value(); - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) { (Some(ExecCommand::Review(review_cli)), _, _) => { @@ -1169,7 +1169,7 @@ fn session_configured_from_thread_start_response( config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), ) } @@ -1192,7 +1192,7 @@ fn session_configured_from_thread_resume_response( config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), ) } diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 36099fe2e8..58bdc9dfc4 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -194,7 +194,7 @@ impl MemoryStartupContext { prompt, &context.model_info, &context.session_telemetry, - context.reasoning_effort, + context.reasoning_effort.clone(), context.reasoning_summary, context.service_tier.clone(), context.turn_metadata_header.as_deref(), diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 1da6497eb0..f4534a0791 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -458,7 +458,7 @@ impl SessionTelemetry { auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, - reasoning_effort = reasoning_effort.map(|e| e.to_string()), + reasoning_effort = reasoning_effort.as_ref().map(ToString::to_string), reasoning_summary = %reasoning_summary, context_window = context_window, auto_compact_token_limit = auto_compact_token_limit, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 23a44c48e6..89b10e383e 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -59,4 +59,5 @@ tempfile = { workspace = true } [package.metadata.cargo-shear] # Required because: # `icu_provider`: contains a required `sync` feature for `icu_decimal` -ignored = ["icu_provider"] +# `strum`: is referenced by generated `EnumIter` derive implementations +ignored = ["icu_provider", "strum"] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 9e5c47a5c1..6654f3166b 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -635,7 +635,7 @@ impl CollaborationMode { } pub fn reasoning_effort(&self) -> Option { - self.settings_ref().reasoning_effort + self.settings_ref().reasoning_effort.clone() } /// Updates the collaboration mode with new model and/or effort values. @@ -654,7 +654,7 @@ impl CollaborationMode { let settings = self.settings_ref(); let updated_settings = Settings { model: model.unwrap_or_else(|| settings.model.clone()), - reasoning_effort: effort.unwrap_or(settings.reasoning_effort), + reasoning_effort: effort.unwrap_or_else(|| settings.reasoning_effort.clone()), developer_instructions: developer_instructions .unwrap_or_else(|| settings.developer_instructions.clone()), }; @@ -676,7 +676,10 @@ impl CollaborationMode { mode: mask.mode.unwrap_or(self.mode), settings: Settings { model: mask.model.clone().unwrap_or_else(|| settings.model.clone()), - reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort), + reasoning_effort: mask + .reasoning_effort + .clone() + .unwrap_or_else(|| settings.reasoning_effort.clone()), developer_instructions: mask .developer_instructions .clone() diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4b678a9209..fc4065ed99 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -4,14 +4,22 @@ //! are used to preserve compatibility when older payloads omit newly introduced attributes. use std::collections::HashMap; +use std::fmt; use std::str::FromStr; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use schemars::schema::StringValidation; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::Serializer; use serde::de::DeserializeOwned; -use strum::IntoEnumIterator; +use serde::de::Error; use strum_macros::Display; use strum_macros::EnumIter; use tracing::warn; @@ -28,23 +36,8 @@ const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub const SPEED_TIER_FAST: &str = "fast"; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive( - Debug, - Serialize, - Deserialize, - Default, - Clone, - Copy, - PartialEq, - Eq, - Display, - JsonSchema, - TS, - EnumIter, - Hash, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] pub enum ReasoningEffort { None, Minimal, @@ -53,14 +46,113 @@ pub enum ReasoningEffort { Medium, High, XHigh, + /// A model-defined effort value that this client does not know yet. + Custom(String), +} + +impl ReasoningEffort { + /// Returns the exact value used on the wire. + pub fn as_str(&self) -> &str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Custom(effort) => effort, + } + } + + /// Returns the built-in effort values in ascending order. + pub fn known_values() -> impl DoubleEndedIterator + ExactSizeIterator { + [ + Self::None, + Self::Minimal, + Self::Low, + Self::Medium, + Self::High, + Self::XHigh, + ] + .into_iter() + } + + /// Returns the built-in ordering rank, or `None` for model-defined values. + pub const fn known_rank(&self) -> Option { + match self { + Self::None => Some(0), + Self::Minimal => Some(1), + Self::Low => Some(2), + Self::Medium => Some(3), + Self::High => Some(4), + Self::XHigh => Some(5), + Self::Custom(_) => None, + } + } +} + +impl fmt::Display for ReasoningEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl JsonSchema for ReasoningEffort { + fn schema_name() -> String { + "ReasoningEffort".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "A non-empty reasoning effort value advertised by the model.".to_string(), + ), + ..Default::default() + })), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + }) + } +} + +impl Serialize for ReasoningEffort { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ReasoningEffort { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let effort = String::deserialize(deserializer)?; + effort.parse().map_err(D::Error::custom) + } } impl FromStr for ReasoningEffort { type Err = String; fn from_str(s: &str) -> Result { - serde_json::from_value(serde_json::Value::String(s.to_string())) - .map_err(|_| format!("invalid reasoning_effort: {s}")) + match s { + "none" => Ok(Self::None), + "minimal" => Ok(Self::Minimal), + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + "xhigh" => Ok(Self::XHigh), + "" => Err("reasoning_effort must not be empty".to_string()), + effort => Ok(Self::Custom(effort.to_string())), + } } } @@ -579,39 +671,43 @@ fn reasoning_effort_mapping_from_presets( } // Map every canonical effort to the closest supported effort for the new model. - let supported: Vec = presets.iter().map(|p| p.effort).collect(); + let supported: Vec = presets.iter().map(|p| p.effort.clone()).collect(); let mut map = HashMap::new(); - for effort in ReasoningEffort::iter() { - let nearest = nearest_effort(effort, &supported); + for effort in ReasoningEffort::known_values() { + let nearest = nearest_effort(&effort, &supported); map.insert(effort, nearest); } Some(map) } -fn effort_rank(effort: ReasoningEffort) -> i32 { - match effort { - ReasoningEffort::None => 0, - ReasoningEffort::Minimal => 1, - ReasoningEffort::Low => 2, - ReasoningEffort::Medium => 3, - ReasoningEffort::High => 4, - ReasoningEffort::XHigh => 5, - } -} - -fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { - let target_rank = effort_rank(target); +fn nearest_effort(target: &ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { + let Some(target_rank) = target.known_rank() else { + return supported + .iter() + .find(|candidate| *candidate == target) + .unwrap_or(target) + .clone(); + }; supported .iter() - .copied() - .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) + .filter_map(|candidate| { + candidate + .known_rank() + .map(|rank| (rank.abs_diff(target_rank), candidate)) + }) + .min_by_key(|(distance, _)| *distance) + .map(|(_, effort)| effort) + .or_else(|| supported.first()) .unwrap_or(target) + .clone() } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; + use serde_json::from_str; + use serde_json::to_string; fn test_model(spec: Option) -> ModelInfo { ModelInfo { @@ -663,16 +759,58 @@ mod tests { } #[test] - fn reasoning_effort_from_str_accepts_known_values() { - assert_eq!("high".parse(), Ok(ReasoningEffort::High)); - assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal)); + fn reasoning_effort_accepts_known_and_custom_values() { + let custom = ReasoningEffort::Custom("max".to_string()); + let deserialized = from_str::(r#""max""#) + .expect("custom reasoning effort should deserialize"); + let serialized = to_string(&custom).expect("custom reasoning effort should serialize"); + + assert_eq!( + ( + "high".parse(), + "max".parse(), + deserialized, + serialized, + custom.to_string(), + ), + ( + Ok(ReasoningEffort::High), + Ok(custom.clone()), + custom, + r#""max""#.to_string(), + "max".to_string(), + ) + ); } #[test] - fn reasoning_effort_from_str_rejects_unknown_values() { + fn reasoning_effort_rejects_empty_values() { assert_eq!( - "unsupported".parse::(), - Err("invalid reasoning_effort: unsupported".to_string()) + "".parse::(), + Err("reasoning_effort must not be empty".to_string()) + ); + } + + #[test] + fn reasoning_effort_json_schema_is_an_open_string() { + let mut effort_generator = SchemaGenerator::default(); + + assert_eq!( + ReasoningEffort::json_schema(&mut effort_generator), + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "A non-empty reasoning effort value advertised by the model.".to_string(), + ), + ..Default::default() + })), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + }) ); } diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 1bba63fbf3..63cb06fea1 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -75,7 +75,7 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) metadata.cwd = turn_ctx.cwd.clone(); } metadata.model = Some(turn_ctx.model.clone()); - metadata.reasoning_effort = turn_ctx.effort; + metadata.reasoning_effort = turn_ctx.effort.clone(); metadata.sandbox_policy = serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default(); metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy); diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 8f1ac4f556..cfcd49d5f9 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -584,13 +584,13 @@ mod tests { } #[test] - fn thread_row_ignores_unknown_reasoning_effort_values() { + fn thread_row_preserves_model_defined_reasoning_effort_values() { let metadata = ThreadMetadata::try_from(thread_row(Some("future"))) .expect("thread metadata should parse"); assert_eq!( metadata, - expected_thread_metadata(/*reasoning_effort*/ None) + expected_thread_metadata(Some(ReasoningEffort::Custom("future".to_string()))) ); } } diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index b02fc15855..3a44c26b32 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -369,7 +369,7 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.model_provider.clone()) .unwrap_or_else(|| "test".to_string()), model: metadata.and_then(|metadata| metadata.model.clone()), - reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort), + reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort.clone()), created_at: metadata .and_then(|metadata| metadata.created_at) .unwrap_or_else(Utc::now), diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d5673b3665..4fb9f398c1 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -232,7 +232,7 @@ impl ThreadMetadataSync { update.cwd = Some(turn_ctx.cwd.clone()); } update.model = Some(turn_ctx.model.clone()); - update.reasoning_effort = turn_ctx.effort; + update.reasoning_effort = turn_ctx.effort.clone(); update.approval_mode = Some(turn_ctx.approval_policy); update.permission_profile = Some(turn_ctx.permission_profile()); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index ec72c5974a..946223ec3e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -712,21 +712,17 @@ impl App { .add_info_message("Reset local memories.".to_string(), /*hint*/ None); } - pub(super) fn reasoning_label(reasoning_effort: Option) -> &'static str { + pub(super) fn reasoning_label(reasoning_effort: Option<&ReasoningEffortConfig>) -> String { match reasoning_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", + None | Some(ReasoningEffortConfig::None) => "default".to_string(), + Some(reasoning_effort) => reasoning_effort.as_str().to_string(), } } pub(super) fn reasoning_label_for( model: &str, - reasoning_effort: Option, - ) -> Option<&'static str> { + reasoning_effort: Option<&ReasoningEffortConfig>, + ) -> Option { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } @@ -737,7 +733,7 @@ impl App { pub(super) fn on_update_reasoning_effort(&mut self, effort: Option) { // TODO(aibrahim): Remove this and don't use config as a state object. // Instead, explicitly pass the stored collaboration mode's effort into new sessions. - self.config.model_reasoning_effort = effort; + self.config.model_reasoning_effort = effort.clone(); self.chat_widget.set_reasoning_effort(effort); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b4dfb93fb7..58f090e4c7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -750,7 +750,7 @@ impl App { self.chat_widget.on_connectors_loaded(result, is_final); } AppEvent::UpdateReasoningEffort(effort) => { - self.on_update_reasoning_effort(effort); + self.on_update_reasoning_effort(effort.clone()); self.sync_active_thread_reasoning_setting(app_server, effort) .await; } @@ -1300,19 +1300,23 @@ impl App { AppEvent::PersistModelSelection { model, effort } => { match crate::config_update::write_config_batch( app_server.request_handle(), - crate::config_update::build_model_selection_edits(model.as_str(), effort), + crate::config_update::build_model_selection_edits( + model.as_str(), + effort.as_ref(), + ), ) .await { Ok(_) => { let effort_label = effort - .map(|selected_effort| selected_effort.to_string()) + .as_ref() + .map(std::string::ToString::to_string) .unwrap_or_else(|| "default".to_string()); tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort) { + if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) { message.push(' '); - message.push_str(label); + message.push_str(&label); } self.chat_widget.add_info_message(message, /*hint*/ None); } @@ -1607,7 +1611,7 @@ impl App { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { - self.config.plan_mode_reasoning_effort = effort; + self.config.plan_mode_reasoning_effort = effort.clone(); self.chat_widget.set_plan_mode_reasoning_effort(effort); self.sync_active_thread_plan_mode_reasoning_setting(app_server) .await; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 802cda3f80..307c69bbc8 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -152,9 +152,11 @@ pub(super) fn apply_accepted_model_migration( }); config.model = Some(target_model.clone()); - config.model_reasoning_effort = Some(target_default_effort); + config.model_reasoning_effort = Some(target_default_effort.clone()); app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(Some(target_default_effort))); + app_event_tx.send(AppEvent::UpdateReasoningEffort(Some( + target_default_effort.clone(), + ))); app_event_tx.send(AppEvent::PersistModelSelection { model: target_model, effort: Some(target_default_effort), @@ -290,7 +292,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed( app_event_tx, model.to_string(), target_model.clone(), - target_preset.default_reasoning_effort, + target_preset.default_reasoning_effort.clone(), ); } ModelMigrationOutcome::Rejected => { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index eb5e10c50f..b18726004e 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5241,7 +5241,7 @@ async fn override_turn_context_sends_thread_settings_update() { .expect("thread/start should succeed"); let thread_id = started.session.thread_id; let initial_model = started.session.model.clone(); - let initial_effort = started.session.reasoning_effort; + let initial_effort = started.session.reasoning_effort.clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); @@ -5466,7 +5466,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode model: "gpt-plan".to_string(), model_provider: "openai".to_string(), service_tier: None, - effort: collaboration_mode.settings.reasoning_effort, + effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), personality: Some(Personality::Pragmatic), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 1bd28d5f65..c53cfc05e3 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -606,7 +606,7 @@ impl App { permissions_override, config.permissions.user_visible_workspace_roots(), model.to_string(), - *effort, + effort.clone(), *summary, service_tier.clone(), collaboration_mode.clone(), diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 27c435af3f..84dcebe1a8 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -124,7 +124,7 @@ impl App { .as_ref() .map(|profile| profile.id.clone()), model: model.clone(), - effort: effort.unwrap_or_default(), + effort: effort.clone().unwrap_or_default(), summary: *summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), @@ -172,7 +172,7 @@ impl App { fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { if settings.collaboration_mode.mode == ModeKind::Default { session.model = settings.model.clone(); - session.reasoning_effort = settings.effort; + session.reasoning_effort = settings.effort.clone(); } session.model_provider_id = settings.model_provider.clone(); session.service_tier = settings.service_tier.clone(); @@ -190,7 +190,7 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: .settings .model .clone_from(&settings.model); - collaboration_mode.settings.reasoning_effort = settings.effort; + collaboration_mode.settings.reasoning_effort = settings.effort.clone(); session.collaboration_mode = Some(Box::new(collaboration_mode)); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2290640ab4..04f81fa83e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1273,7 +1273,8 @@ fn config_request_overrides_from_config( "model_reasoning_effort", config .model_reasoning_effort - .map(|effort| effort.to_string()), + .as_ref() + .map(std::string::ToString::to_string), ); insert( "model_reasoning_summary", @@ -1586,7 +1587,7 @@ async fn thread_session_state_from_thread_start_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await @@ -1627,7 +1628,7 @@ async fn thread_session_state_from_thread_resume_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await @@ -1659,7 +1660,7 @@ async fn thread_session_state_from_thread_fork_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 3930224e04..ddb118baec 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -166,7 +166,7 @@ impl ChatWidget { mut collaboration_mode: CollaborationModeMask, ) { if collaboration_mode.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort + && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { collaboration_mode.reasoning_effort = Some(Some(effort)); } diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 2c0d5aba95..8ddecaa3db 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -100,11 +100,11 @@ impl ChatWidget { let model = preset.model.clone(); let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( model.as_str(), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), ); let actions = Self::model_selection_actions( model.clone(), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), should_prompt_plan_mode_scope, ); SelectionItem { @@ -222,16 +222,16 @@ impl ChatWidget { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), - effort: effort_for_action, + effort: effort_for_action.clone(), }); return; } tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), - effort: effort_for_action, + effort: effort_for_action.clone(), }); })] } @@ -261,30 +261,34 @@ impl ChatWidget { model: String, effort: Option, ) { - let reasoning_phrase = match effort { + let reasoning_phrase = match effort.as_ref() { Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), Some(selected_effort) => { format!( "{} reasoning", - Self::reasoning_effort_label(selected_effort).to_lowercase() + Self::reasoning_effort_sentence_label(selected_effort) ) } None => "the selected reasoning".to_string(), }; let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); let plan_reasoning_source = if let Some(plan_override) = - self.config.plan_mode_reasoning_effort + self.config.plan_mode_reasoning_effort.as_ref() { format!( "user-chosen Plan override ({})", - Self::reasoning_effort_label(plan_override).to_lowercase() + Self::reasoning_effort_sentence_label(plan_override) ) } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - match plan_mask.reasoning_effort.flatten() { + match plan_mask + .reasoning_effort + .as_ref() + .and_then(|effort| effort.as_ref()) + { Some(plan_effort) => format!( "built-in Plan default ({})", - Self::reasoning_effort_label(plan_effort).to_lowercase() + Self::reasoning_effort_sentence_label(plan_effort) ), None => "built-in Plan default (no reasoning)".to_string(), } @@ -298,20 +302,21 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); + let effort = effort.clone(); move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::UpdateReasoningEffort(effort.clone())); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model.clone(), - effort, + effort: effort.clone(), }); })]; @@ -344,7 +349,7 @@ impl ChatWidget { /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let default_effort = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; let in_plan_mode = self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; @@ -362,7 +367,7 @@ impl ChatWidget { } else { None }; - let warning_text = warn_effort.map(|effort| { + let warning_text = warn_effort.as_ref().map(|effort| { let effort_label = Self::reasoning_effort_label(effort); format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") }); @@ -370,30 +375,25 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } + let mut choices: Vec = ReasoningEffortConfig::known_values() + .filter(|effort| supported.iter().any(|option| option.effort == *effort)) + .collect(); + choices.extend( + supported + .iter() + .filter(|option| option.effort.known_rank().is_none()) + .map(|option| option.effort.clone()), + ); if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); + choices.push(default_effort.clone()); } if choices.len() == 1 { - let selected_effort = choices.first().and_then(|c| c.stored); + let selected_effort = choices.first().cloned(); let selected_model = preset.model; - if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { + if self + .should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort.clone()) + { self.app_event_tx .send(AppEvent::OpenPlanReasoningScopePrompt { model: selected_model, @@ -405,12 +405,10 @@ impl ChatWidget { return; } - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) + let default_choice = choices + .contains(&default_effort) + .then(|| default_effort.clone()) + .or_else(|| choices.first().cloned()) .or(Some(default_effort)); let model_slug = preset.model.to_string(); @@ -419,40 +417,33 @@ impl ChatWidget { if in_plan_mode { self.config .plan_mode_reasoning_effort - .or(self.effective_reasoning_effort()) + .clone() + .or_else(|| self.effective_reasoning_effort()) } else { self.effective_reasoning_effort() } } else { - default_choice + default_choice.clone() }; - let selection_choice = highlight_choice.or(default_choice); + let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone()); let initial_selected_idx = choices .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); + .position(|choice| Some(choice) == selection_choice.as_ref()); let mut items: Vec = Vec::new(); for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { + let effort = choice.clone(); + let mut effort_label = Self::reasoning_effort_label(&effort); + if Some(choice) == default_choice.as_ref() { effort_label.push_str(" (default)"); } - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) + let description = supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) .filter(|text| !text.is_empty()); - let show_warning = warn_for_model && warn_effort == Some(effort); + let show_warning = warn_for_model && warn_effort.as_ref() == Some(&effort); let selected_description = if show_warning { warning_text.as_ref().map(|warning_message| { description.as_ref().map_or_else( @@ -465,21 +456,23 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let choice_effort = choice.stored; - let should_prompt_plan_mode_scope = - self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); + let choice_effort = Some(effort); + let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( + model_slug.as_str(), + choice_effort.clone(), + ); let actions: Vec = vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), - effort: choice_effort, + effort: choice_effort.clone(), }); } else { tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); + tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), - effort: choice_effort, + effort: choice_effort.clone(), }); } })]; @@ -488,7 +481,7 @@ impl ChatWidget { name: effort_label, description, selected_description, - is_current: is_current_model && choice.stored == highlight_choice, + is_current: is_current_model && Some(choice) == highlight_choice.as_ref(), actions, dismiss_on_select: true, ..Default::default() @@ -509,14 +502,22 @@ impl ChatWidget { }); } - pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + pub(super) fn reasoning_effort_label(effort: &ReasoningEffortConfig) -> String { match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", + ReasoningEffortConfig::None => "None".to_string(), + ReasoningEffortConfig::Minimal => "Minimal".to_string(), + ReasoningEffortConfig::Low => "Low".to_string(), + ReasoningEffortConfig::Medium => "Medium".to_string(), + ReasoningEffortConfig::High => "High".to_string(), + ReasoningEffortConfig::XHigh => "Extra high".to_string(), + ReasoningEffortConfig::Custom(value) => value.clone(), + } + } + + pub(super) fn reasoning_effort_sentence_label(effort: &ReasoningEffortConfig) -> String { + match effort { + ReasoningEffortConfig::Custom(value) => value.clone(), + effort => Self::reasoning_effort_label(effort).to_lowercase(), } } @@ -531,7 +532,7 @@ impl ChatWidget { } fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); + self.apply_model_and_effort_without_persist(model.clone(), effort.clone()); self.app_event_tx .send(AppEvent::PersistModelSelection { model, effort }); } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index f831b5a007..efb0b63628 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -343,14 +343,16 @@ impl ChatWidget { /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), - Some(Some(default_effort)), + Some(Some(default_effort.clone())), /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ))); tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + tx.send(AppEvent::UpdateReasoningEffort(Some( + default_effort.clone(), + ))); })]; let keep_actions: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index 612dbe7ddd..a5102888b1 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -8,15 +8,14 @@ //! The shortcut state machine is deliberately narrow: it only handles key //! presses when no modal or popup owns input, it anchors unset reasoning to the //! current model preset's default, and it walks only efforts advertised by the -//! active model. Unsupported current efforts are not normalized eagerly; the -//! next shortcut moves to the nearest supported effort in the requested -//! direction. +//! active model. Unsupported known efforts move to the nearest advertised known +//! effort in the requested direction. Unknown efforts anchor to the model +//! default before stepping through the advertised order. use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use crossterm::event::KeyEvent; -use strum::IntoEnumIterator; use super::ChatWidget; use crate::app_event::AppEvent; @@ -30,8 +29,8 @@ pub(super) enum ReasoningShortcutDirection { } impl ReasoningShortcutDirection { - fn bound_message(self, effort: ReasoningEffortConfig) -> String { - let label = ChatWidget::reasoning_effort_label(effort).to_lowercase(); + fn bound_message(self, effort: &ReasoningEffortConfig) -> String { + let label = ChatWidget::reasoning_effort_sentence_label(effort); match self { Self::Lower => format!("Reasoning is already at the lowest level ({label})."), Self::Raise => format!("Reasoning is already at the highest level ({label})."), @@ -90,12 +89,23 @@ impl ChatWidget { }; let choices = reasoning_choices(&preset); - let current_effort = self + let configured_effort = self .effective_reasoning_effort() - .unwrap_or(preset.default_reasoning_effort); - let Some(next_effort) = next_reasoning_effort(&choices, Some(current_effort), direction) + .unwrap_or_else(|| preset.default_reasoning_effort.clone()); + let current_effort = if choices.contains(&configured_effort) { + configured_effort + } else if choices.contains(&preset.default_reasoning_effort) { + preset.default_reasoning_effort + } else { + choices + .first() + .cloned() + .unwrap_or(preset.default_reasoning_effort) + }; + let Some(next_effort) = + next_reasoning_effort(&choices, Some(current_effort.clone()), direction) else { - self.add_info_message(direction.bound_message(current_effort), /*hint*/ None); + self.add_info_message(direction.bound_message(¤t_effort), /*hint*/ None); return true; }; @@ -120,18 +130,23 @@ impl ChatWidget { } fn reasoning_choices(preset: &ModelPreset) -> Vec { - let mut choices = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if preset + let mut choices: Vec = ReasoningEffortConfig::known_values() + .filter(|effort| { + preset + .supported_reasoning_efforts + .iter() + .any(|option| option.effort == *effort) + }) + .collect(); + choices.extend( + preset .supported_reasoning_efforts .iter() - .any(|option| option.effort == effort) - { - choices.push(effort); - } - } + .filter(|option| option.effort.known_rank().is_none()) + .map(|option| option.effort.clone()), + ); if choices.is_empty() { - choices.push(preset.default_reasoning_effort); + choices.push(preset.default_reasoning_effort.clone()); } choices } @@ -142,32 +157,51 @@ fn next_reasoning_effort( direction: ReasoningShortcutDirection, ) -> Option { let current_effort = current_effort?; - if choices.is_empty() { - return None; + if let Some(current_index) = choices.iter().position(|choice| choice == ¤t_effort) { + return match direction { + ReasoningShortcutDirection::Lower => current_index + .checked_sub(1) + .and_then(|index| choices.get(index)) + .cloned(), + ReasoningShortcutDirection::Raise => choices.get(current_index + 1).cloned(), + }; } - let current_rank = effort_rank(current_effort); - match direction { + let current_rank = current_effort.known_rank()?; + let ranked_choice = match direction { ReasoningShortcutDirection::Lower => choices .iter() - .rev() - .copied() - .find(|choice| effort_rank(*choice) < current_rank), + .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) + .filter(|(rank, _)| *rank < current_rank) + .max_by_key(|(rank, _)| *rank) + .map(|(_, choice)| choice.clone()), ReasoningShortcutDirection::Raise => choices .iter() - .copied() - .find(|choice| effort_rank(*choice) > current_rank), + .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) + .filter(|(rank, _)| *rank > current_rank) + .min_by_key(|(rank, _)| *rank) + .map(|(_, choice)| choice.clone()), + }; + if let Some(ranked_choice) = ranked_choice { + return Some(ranked_choice); } -} -fn effort_rank(effort: ReasoningEffortConfig) -> i32 { - match effort { - ReasoningEffortConfig::None => 0, - ReasoningEffortConfig::Minimal => 1, - ReasoningEffortConfig::Low => 2, - ReasoningEffortConfig::Medium => 3, - ReasoningEffortConfig::High => 4, - ReasoningEffortConfig::XHigh => 5, + let nearest_known_index = choices + .iter() + .enumerate() + .filter_map(|(index, choice)| { + choice + .known_rank() + .map(|rank| (rank.abs_diff(current_rank), index)) + }) + .min_by_key(|(distance, _)| *distance) + .map(|(_, index)| index)?; + match direction { + ReasoningShortcutDirection::Lower => nearest_known_index + .checked_sub(1) + .and_then(|index| choices.get(index)) + .cloned(), + ReasoningShortcutDirection::Raise => choices.get(nearest_known_index + 1).cloned(), } } @@ -235,6 +269,21 @@ mod tests { ); } + #[test] + fn next_reasoning_effort_reaches_custom_level_from_nearest_known_anchor() { + let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); + let choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()]; + + assert_eq!( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::High), + ReasoningShortcutDirection::Raise, + ), + Some(custom_effort) + ); + } + #[test] fn next_reasoning_effort_clamps_at_bounds() { let choices = vec![ diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 6eb5910afe..004f64c726 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -79,7 +79,7 @@ impl ChatWidget { let default_model = session.model.clone(); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(default_model.clone()), - Some(session.reasoning_effort), + Some(session.reasoning_effort.clone()), /*developer_instructions*/ None, ); match session.collaboration_mode.as_deref() { @@ -93,7 +93,7 @@ impl ChatWidget { Some(&default_model), ); if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.reasoning_effort = Some(session.reasoning_effort); + mask.reasoning_effort = Some(session.reasoning_effort.clone()); } self.update_collaboration_mode_indicator(); self.refresh_plan_mode_nudge(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 45dbc362e9..c05788acaf 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -159,7 +159,7 @@ impl ChatWidget { /// so the footer reflects it without waiting for the next mode switch. /// Passing `None` resets to the Plan-mode preset default. pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { - self.config.plan_mode_reasoning_effort = effort; + self.config.plan_mode_reasoning_effort = effort.clone(); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() && mask.mode == Some(ModeKind::Plan) @@ -182,7 +182,7 @@ impl ChatWidget { pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.current_collaboration_mode = self.current_collaboration_mode.with_updates( /*model*/ None, - Some(effort), + Some(effort.clone()), /*developer_instructions*/ None, ); if self.collaboration_modes_enabled() @@ -471,7 +471,7 @@ impl ChatWidget { let current_effort = self.current_collaboration_mode.reasoning_effort(); self.active_collaboration_mask .as_ref() - .and_then(|mask| mask.reasoning_effort) + .and_then(|mask| mask.reasoning_effort.clone()) .unwrap_or(current_effort) } @@ -590,7 +590,7 @@ impl ChatWidget { name: mode_kind.display_name().to_string(), mode: Some(mode_kind), model: Some(settings.model.clone()), - reasoning_effort: Some(settings.reasoning_effort), + reasoning_effort: Some(settings.reasoning_effort.clone()), developer_instructions: Some(settings.developer_instructions), }); self.update_collaboration_mode_indicator(); @@ -712,7 +712,7 @@ impl ChatWidget { let previous_model = self.current_model().to_string(); let previous_effort = self.effective_reasoning_effort(); if mask.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort + && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { mask.reasoning_effort = Some(Some(effort)); } @@ -732,13 +732,9 @@ impl ChatWidget { { let mut message = format!("Model changed to {next_model}"); if !next_model.starts_with("codex-auto-") { - let reasoning_label = match next_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", + let reasoning_label = match next_effort.as_ref() { None | Some(ReasoningEffortConfig::None) => "default", + Some(effort) => effort.as_str(), }; message.push(' '); message.push_str(reasoning_label); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index baef861f4c..8a37596df7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -8,5 +8,6 @@ expression: popup 2. Medium (default) Balances speed and reasoning depth for everyday tasks › 3. High (current) Greater reasoning depth for complex problems 4. Extra high Extra high reasoning depth for complex problems + 5. max Maximum available reasoning Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d0394464fa..d4de738314 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -212,7 +212,7 @@ impl ChatWidget { }); let reasoning_effort_override = Some( self.effective_reasoning_effort() - .or(self.config.model_reasoning_effort) + .or_else(|| self.config.model_reasoning_effort.clone()) .or(model_default_reasoning_effort), ); let rate_limit_snapshots: Vec = self @@ -382,15 +382,11 @@ impl ChatWidget { } pub(super) fn status_line_reasoning_effort_label( - effort: Option, - ) -> &'static str { + effort: Option<&ReasoningEffortConfig>, + ) -> String { match effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", + None | Some(ReasoningEffortConfig::None) => "default".to_string(), + Some(effort) => effort.as_str().to_string(), } } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d8facdb0cf..592dd04381 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -562,7 +562,7 @@ impl ChatWidget { match item { StatusLineItem::ModelName => Some(self.model_display_name().to_string()), StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()), - StatusLineItem::Reasoning => Some(self.reasoning_display_name().to_string()), + StatusLineItem::Reasoning => Some(self.reasoning_display_name()), StatusLineItem::CurrentDir => { Some(format_directory_display( self.status_line_cwd(), @@ -762,15 +762,16 @@ impl ChatWidget { /*max_chars*/ 32, )), TerminalTitleItem::Reasoning => Some(Self::truncate_terminal_title_part( - self.reasoning_display_name().to_string(), + self.reasoning_display_name(), /*max_chars*/ 32, )), TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(), } } - fn reasoning_display_name(&self) -> &'static str { - Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()) + fn reasoning_display_name(&self) -> String { + let effort = self.effective_reasoning_effort(); + Self::status_line_reasoning_effort_label(effort.as_ref()) } fn model_with_reasoning_display_name(&self) -> String { diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index bcc960e647..ae19fd6f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2405,13 +2405,54 @@ async fn model_reasoning_selection_popup_snapshot() { set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - let preset = get_available_model(&chat, "gpt-5.4"); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: ReasoningEffortConfig::Custom("max".to_string()), + description: "Maximum available reasoning".to_string(), + }); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_reasoning_selection_popup", popup); } +#[tokio::test] +async fn model_reasoning_selection_popup_applies_custom_effort() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: custom_effort.clone(), + description: "Maximum available reasoning".to_string(), + }); + chat.open_reasoning_popup(preset); + while rx.try_recv().is_ok() {} + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let selected_effort_events = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::UpdateReasoningEffort(effort) => Some((None, effort)), + AppEvent::PersistModelSelection { model, effort } => Some((Some(model), effort)), + _ => None, + }) + .collect::>(); + assert_eq!( + selected_effort_events, + vec![ + (None, Some(custom_effort.clone())), + (Some("gpt-5.4".to_string()), Some(custom_effort)), + ] + ); +} + #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 23e8836454..c63f6f746c 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -149,7 +149,7 @@ pub(crate) fn new_session_info( // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( session.model.clone(), - session.reasoning_effort, + session.reasoning_effort.clone(), show_fast_status, config.cwd.to_path_buf(), CODEX_CLI_VERSION, @@ -317,15 +317,10 @@ impl SessionHeaderHistoryCell { formatted } - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) + fn reasoning_label(&self) -> Option<&str> { + self.reasoning_effort + .as_ref() + .map(ReasoningEffortConfig::as_str) } } @@ -368,7 +363,7 @@ impl HistoryCell for SessionHeaderHistoryCell { ]; if let Some(reasoning) = reasoning_label { spans.push(Span::from(" ")); - spans.push(Span::from(reasoning)); + spans.push(Span::from(reasoning.to_owned())); } if self.show_fast_status { spans.push(" ".into()); diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 3273c610ae..0532ea9fb5 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -182,7 +182,7 @@ pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option Some(SpawnRequestSummary { model: model.clone(), - reasoning_effort: *reasoning_effort, + reasoning_effort: reasoning_effort.clone(), }), _ => None, } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b8c9c16530..61ffd3da7d 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -287,7 +287,7 @@ impl StatusHistoryCell { ]; if config.model_provider.wire_api == WireApi::Responses { let effort_value = reasoning_effort_override - .unwrap_or(config.model_reasoning_effort) + .unwrap_or_else(|| config.model_reasoning_effort.clone()) .map(|effort| effort.to_string()) .unwrap_or_else(|| "none".to_string()); config_entries.push(("reasoning effort", effort_value)); diff --git a/codex-rs/utils/sandbox-summary/src/config_summary.rs b/codex-rs/utils/sandbox-summary/src/config_summary.rs index b3de5b6382..7e0f7dc4e9 100644 --- a/codex-rs/utils/sandbox-summary/src/config_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/config_summary.rs @@ -25,7 +25,8 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta if config.model_provider.wire_api == WireApi::Responses { let reasoning_effort = config .model_reasoning_effort - .map(|effort| effort.to_string()); + .as_ref() + .map(std::string::ToString::to_string); entries.push(( "reasoning effort", reasoning_effort.unwrap_or_else(|| "none".to_string()), From f6e529656f7ce4af6d146f4b0c8500a6d89221c2 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Jun 2026 14:01:14 -0700 Subject: [PATCH 041/141] [codex] Use model-advertised reasoning effort order (#26446) ## Summary - preserve the model catalog order for app-server `supportedReasoningEfforts` and document that client contract - render TUI reasoning choices in the advertised order - step reasoning shortcuts by adjacent list position instead of deriving order from known effort names - anchor unsupported configured values to the advertised default, or the first option when needed - remove canonical effort ordering helpers and the unused upgrade effort mapping ## Validation - `just fmt` - Local tests and compilation were not run per request; relying on CI. Stacked on #26444. --- codex-rs/app-server/README.md | 2 +- .../app-server/tests/suite/v2/model_list.rs | 22 +++- codex-rs/protocol/src/openai_models.rs | 70 ----------- codex-rs/tui/src/app/startup_prompts.rs | 1 - codex-rs/tui/src/app/tests/model_catalog.rs | 1 - codex-rs/tui/src/app_server_session.rs | 1 - codex-rs/tui/src/chatwidget/model_popups.rs | 11 +- .../tui/src/chatwidget/reasoning_shortcuts.rs | 116 +++++++----------- ...ests__model_reasoning_selection_popup.snap | 6 +- .../chatwidget/tests/popups_and_settings.rs | 9 +- 10 files changed, 73 insertions(+), 166 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a789c61af0..6d705afa98 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index eba04b54bd..d2538629c8 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use anyhow::Error; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; @@ -166,8 +167,9 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "description": "Remote-only model for app-server model/list coverage", "default_reasoning_level": "max", "supported_reasoning_levels": [ - {"effort": "low", "description": "low"}, - {"effort": "max", "description": "Maximum"} + {"effort": "max", "description": "Maximum"}, + {"effort": "low", "description": "Low"}, + {"effort": "focused", "description": "Focused"} ], "shell_type": "shell_command", "visibility": "list", @@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1" } = to_response::(response)?; let mut expected_presets: Vec = vec![remote_model.into()]; ModelPreset::mark_default_by_picker_visibility(&mut expected_presets); - let expected_items = expected_presets + let mut expected_items = expected_presets .iter() .map(model_from_preset) .collect::>(); + expected_items[0].supported_reasoning_efforts = vec![ + ReasoningEffortOption { + reasoning_effort: "max".parse().map_err(Error::msg)?, + description: "Maximum".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: "low".parse().map_err(Error::msg)?, + description: "Low".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: "focused".parse().map_err(Error::msg)?, + description: "Focused".to_string(), + }, + ]; assert_eq!(items, expected_items); assert!(next_cursor.is_none()); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index fc4065ed99..4c04edbb56 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,7 +3,6 @@ //! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults //! are used to preserve compatibility when older payloads omit newly introduced attributes. -use std::collections::HashMap; use std::fmt; use std::str::FromStr; @@ -63,32 +62,6 @@ impl ReasoningEffort { Self::Custom(effort) => effort, } } - - /// Returns the built-in effort values in ascending order. - pub fn known_values() -> impl DoubleEndedIterator + ExactSizeIterator { - [ - Self::None, - Self::Minimal, - Self::Low, - Self::Medium, - Self::High, - Self::XHigh, - ] - .into_iter() - } - - /// Returns the built-in ordering rank, or `None` for model-defined values. - pub const fn known_rank(&self) -> Option { - match self { - Self::None => Some(0), - Self::Minimal => Some(1), - Self::Low => Some(2), - Self::Medium => Some(3), - Self::High => Some(4), - Self::XHigh => Some(5), - Self::Custom(_) => None, - } - } } impl fmt::Display for ReasoningEffort { @@ -200,7 +173,6 @@ pub struct ReasoningEffortPreset { #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ModelUpgrade { pub id: String, - pub reasoning_effort_mapping: Option>, pub migration_config_key: String, pub model_link: Option, pub upgrade_copy: Option, @@ -593,9 +565,6 @@ impl From for ModelPreset { is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), - reasoning_effort_mapping: reasoning_effort_mapping_from_presets( - &info.supported_reasoning_levels, - ), migration_config_key: info.slug.clone(), // todo(aibrahim): add the model link here. model_link: None, @@ -663,45 +632,6 @@ impl ModelPreset { } } -fn reasoning_effort_mapping_from_presets( - presets: &[ReasoningEffortPreset], -) -> Option> { - if presets.is_empty() { - return None; - } - - // Map every canonical effort to the closest supported effort for the new model. - let supported: Vec = presets.iter().map(|p| p.effort.clone()).collect(); - let mut map = HashMap::new(); - for effort in ReasoningEffort::known_values() { - let nearest = nearest_effort(&effort, &supported); - map.insert(effort, nearest); - } - Some(map) -} - -fn nearest_effort(target: &ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { - let Some(target_rank) = target.known_rank() else { - return supported - .iter() - .find(|candidate| *candidate == target) - .unwrap_or(target) - .clone(); - }; - supported - .iter() - .filter_map(|candidate| { - candidate - .known_rank() - .map(|rank| (rank.abs_diff(target_rank), candidate)) - }) - .min_by_key(|(distance, _)| *distance) - .map(|(_, effort)| effort) - .or_else(|| supported.first()) - .unwrap_or(target) - .clone() -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 307c69bbc8..a027dcd505 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -242,7 +242,6 @@ pub(super) async fn handle_model_migration_prompt_if_needed( if let Some(ModelUpgrade { id: target_model, - reasoning_effort_mapping: _, migration_config_key, model_link, upgrade_copy, diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index 7d2eb9c125..961c65159a 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -297,7 +297,6 @@ async fn model_migration_prompt_skips_when_target_missing_or_hidden() { .expect("preset present"); current.upgrade = Some(ModelUpgrade { id: "missing-target".to_string(), - reasoning_effort_mapping: None, migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), model_link: None, upgrade_copy: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 04f81fa83e..ccd6304d76 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1204,7 +1204,6 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { let upgrade_info = model.upgrade_info.clone(); ModelUpgrade { id: upgrade_id, - reasoning_effort_mapping: None, migration_config_key: model.model.clone(), model_link: upgrade_info .as_ref() diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 8ddecaa3db..90fd6c2cc3 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -375,15 +375,10 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); - let mut choices: Vec = ReasoningEffortConfig::known_values() - .filter(|effort| supported.iter().any(|option| option.effort == *effort)) + let mut choices: Vec = supported + .iter() + .map(|option| option.effort.clone()) .collect(); - choices.extend( - supported - .iter() - .filter(|option| option.effort.known_rank().is_none()) - .map(|option| option.effort.clone()), - ); if choices.is_empty() { choices.push(default_effort.clone()); } diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index a5102888b1..404bf87029 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -8,9 +8,9 @@ //! The shortcut state machine is deliberately narrow: it only handles key //! presses when no modal or popup owns input, it anchors unset reasoning to the //! current model preset's default, and it walks only efforts advertised by the -//! active model. Unsupported known efforts move to the nearest advertised known -//! effort in the requested direction. Unknown efforts anchor to the model -//! default before stepping through the advertised order. +//! active model. Unsupported efforts anchor to the model default, or the first +//! advertised effort when the default is absent, before stepping through the +//! advertised order. use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; @@ -130,21 +130,11 @@ impl ChatWidget { } fn reasoning_choices(preset: &ModelPreset) -> Vec { - let mut choices: Vec = ReasoningEffortConfig::known_values() - .filter(|effort| { - preset - .supported_reasoning_efforts - .iter() - .any(|option| option.effort == *effort) - }) + let mut choices: Vec = preset + .supported_reasoning_efforts + .iter() + .map(|option| option.effort.clone()) .collect(); - choices.extend( - preset - .supported_reasoning_efforts - .iter() - .filter(|option| option.effort.known_rank().is_none()) - .map(|option| option.effort.clone()), - ); if choices.is_empty() { choices.push(preset.default_reasoning_effort.clone()); } @@ -167,42 +157,7 @@ fn next_reasoning_effort( }; } - let current_rank = current_effort.known_rank()?; - let ranked_choice = match direction { - ReasoningShortcutDirection::Lower => choices - .iter() - .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) - .filter(|(rank, _)| *rank < current_rank) - .max_by_key(|(rank, _)| *rank) - .map(|(_, choice)| choice.clone()), - ReasoningShortcutDirection::Raise => choices - .iter() - .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) - .filter(|(rank, _)| *rank > current_rank) - .min_by_key(|(rank, _)| *rank) - .map(|(_, choice)| choice.clone()), - }; - if let Some(ranked_choice) = ranked_choice { - return Some(ranked_choice); - } - - let nearest_known_index = choices - .iter() - .enumerate() - .filter_map(|(index, choice)| { - choice - .known_rank() - .map(|rank| (rank.abs_diff(current_rank), index)) - }) - .min_by_key(|(distance, _)| *distance) - .map(|(_, index)| index)?; - match direction { - ReasoningShortcutDirection::Lower => nearest_known_index - .checked_sub(1) - .and_then(|index| choices.get(index)) - .cloned(), - ReasoningShortcutDirection::Raise => choices.get(nearest_known_index + 1).cloned(), - } + None } #[cfg(test)] @@ -248,39 +203,52 @@ mod tests { } #[test] - fn next_reasoning_effort_skips_to_supported_level_from_unsupported_current() { + fn next_reasoning_effort_does_not_infer_position_for_unsupported_current() { let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High]; assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::Medium), - ReasoningShortcutDirection::Raise, + ( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::Medium), + ReasoningShortcutDirection::Raise, + ), + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::Medium), + ReasoningShortcutDirection::Lower, + ), ), - Some(ReasoningEffortConfig::High) - ); - assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::Medium), - ReasoningShortcutDirection::Lower, - ), - Some(ReasoningEffortConfig::Low) + (None, None) ); } #[test] - fn next_reasoning_effort_reaches_custom_level_from_nearest_known_anchor() { + fn next_reasoning_effort_uses_advertised_order_for_custom_levels() { let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); - let choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()]; + let choices = vec![ + ReasoningEffortConfig::High, + ReasoningEffortConfig::Low, + custom_effort.clone(), + ]; assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::High), - ReasoningShortcutDirection::Raise, + ( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::High), + ReasoningShortcutDirection::Raise, + ), + next_reasoning_effort( + &choices, + Some(custom_effort), + ReasoningShortcutDirection::Lower, + ), ), - Some(custom_effort) + ( + Some(ReasoningEffortConfig::Low), + Some(ReasoningEffortConfig::Low), + ) ); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 8a37596df7..a436136570 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -6,8 +6,8 @@ expression: popup 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems - 5. max Maximum available reasoning + 3. max Maximum available reasoning +› 4. High (current) Greater reasoning depth for complex problems + 5. Extra high Extra high reasoning depth for complex problems Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ae19fd6f3f..89c332a4ef 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2406,12 +2406,13 @@ async fn model_reasoning_selection_popup_snapshot() { chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let mut preset = get_available_model(&chat, "gpt-5.4"); - preset - .supported_reasoning_efforts - .push(ReasoningEffortPreset { + preset.supported_reasoning_efforts.insert( + 2, + ReasoningEffortPreset { effort: ReasoningEffortConfig::Custom("max".to_string()), description: "Maximum available reasoning".to_string(), - }); + }, + ); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); From 37c8aefa14e1a8bb817ee63ddc4561da04741082 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 4 Jun 2026 14:38:11 -0700 Subject: [PATCH 042/141] Use Winget release environment secret (#26466) ## Why `WINGET_PUBLISH_PAT` now lives as a GitHub environment secret under `mainline-release-winget`. The WinGet release job needs to enter that environment so `secrets.WINGET_PUBLISH_PAT` resolves during stable/mainline Rust releases. ## What Changed - Attach the `winget` job in `.github/workflows/rust-release.yml` to the `mainline-release-winget` environment. - Set `deployment: false` so the job can read environment secrets without creating GitHub deployment records. ## Operational Note The `mainline-release-winget` environment must allow `rust-v*.*.*` tag refs before this can run on release tags. The live environment currently has a custom policy named `rust-v*.*.*` with type `branch`; add the corresponding `tag` policy before relying on this path for a release. ## Validation - `git diff --check origin/main...HEAD -- .github/workflows/rust-release.yml` - `ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "yaml ok: #{f}" }' .github/workflows/rust-release.yml` --- .github/workflows/rust-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2888ea6ce8..03a0cc57ff 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1946,6 +1946,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + environment: + name: mainline-release-winget + deployment: false steps: - name: Publish to WinGet From 59ca34206b4606a8800a4e565d6006f02fa37206 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 15:08:52 -0700 Subject: [PATCH 043/141] [codex] Preserve logical paths during AGENTS.md discovery (#26465) ## Intent Follow up on #26205 by avoiding unnecessary filesystem canonicalization during `AGENTS.md` discovery. The configured working directory is already absolute, and canonicalization incorrectly switches symlinked workspaces from their logical parent hierarchy to the target's hierarchy. ## User-facing behavior For a symlinked working directory such as: ```text test-root/ |-- logical-repo/ | |-- AGENTS.md ("logical parent doc") | `-- workspace ------------> physical-repo/workspace/ `-- physical-repo/ |-- AGENTS.md ("physical parent doc") `-- workspace/ `-- AGENTS.md ("workspace doc") ``` Before this change, Codex canonicalized `logical-repo/workspace` to `physical-repo/workspace` before discovery. It therefore loaded `physical-repo/AGENTS.md` and `physical-repo/workspace/AGENTS.md`, ignoring the instructions from the repository through which the user entered the workspace. After this change, ancestor discovery walks the configured logical path, so Codex loads `logical-repo/AGENTS.md`. Opening `logical-repo/workspace/AGENTS.md` still follows the symlink through the host filesystem, so the workspace document is also loaded. `physical-repo/AGENTS.md` is not loaded. ## Implementation Use the logical absolute working directory when discovering project instructions and reporting instruction sources. Filesystem reads still follow the working-directory symlink, so an `AGENTS.md` in the target workspace continues to load while ancestor discovery uses the symlink's parents. ## Validation Added integration coverage proving that discovery loads the logical parent's instructions and the target workspace's instructions, but not the target parent's instructions. --- .../tests/suite/v2/thread_resume.rs | 2 +- .../app-server/tests/suite/v2/thread_start.rs | 2 +- codex-rs/core/src/agents_md.rs | 5 +- codex-rs/core/src/agents_md_tests.rs | 59 ++++++------ codex-rs/core/tests/common/lib.rs | 15 ++++ codex-rs/core/tests/suite/agents_md.rs | 90 +++++++++++++++++-- 6 files changed, 130 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4e052041ad..4ae68b0e71 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -284,7 +284,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul instruction_sources, .. } = to_response::(start_resp)?; - let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + let project_agents = AbsolutePathBuf::try_from(project_agents)?; assert_eq!(instruction_sources, vec![project_agents.clone()]); let turn_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 208af14299..a2da95545a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, - std::fs::canonicalize(project_agents_path)?, + project_agents_path, ] .into_iter() .map(normalize_path_for_comparison) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bc3740a46f..5739e42f30 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -206,10 +206,7 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let mut dir = self.config.cwd.clone(); - if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { - dir = canonical_dir; - } + let dir = self.config.cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers( diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 95659511b0..d8298dfd7d 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -5,6 +5,7 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::TempDirExt; +use core_test_support::create_directory_symlink; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -223,8 +224,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { .text(); assert_eq!(res, "project\u{FFFD} doc"); - let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); - assert_invalid_utf8_warning(&warnings, "Project", &canonical_path); + assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path()); } /// Oversize file is truncated to `project_doc_max_bytes`. @@ -330,10 +330,7 @@ async fn sourceless_user_instructions_preserve_separator_without_reporting_a_sou .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); assert_eq!( loaded.text(), @@ -385,14 +382,8 @@ async fn concatenates_root_and_cwd_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("doc expected"); - let root_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), - ) - .expect("absolute root doc path"); - let crate_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), - ) - .expect("absolute crate doc path"); + let root_agents = repo.path().join("AGENTS.md").abs(); + let crate_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ InstructionEntry { @@ -434,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { cfg.cwd = nested.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - let expected_parent = AbsolutePathBuf::try_from( - dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"), - ) - .expect("absolute parent doc path"); - let expected_child = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"), - ) - .expect("absolute child doc path"); + let expected_parent = root.path().join("AGENTS.md").abs(); + let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); @@ -450,6 +435,26 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn agents_md_paths_preserve_symlinked_cwd() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("AGENTS.md"), "project doc").unwrap(); + + let linked_cwd = tmp.path().join("linked"); + create_directory_symlink(&target, &linked_cwd); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = linked_cwd.abs(); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "project doc"); +} + #[tokio::test] async fn child_agents_message_after_global_instructions_uses_plain_separator() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -497,10 +502,7 @@ async fn instruction_sources_include_global_before_agents_md_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ @@ -541,10 +543,7 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index be07b803f8..94d72d619b 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -19,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use codex_utils_absolute_path::test_support::PathBufExt; pub use codex_utils_absolute_path::test_support::PathExt; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -110,6 +111,20 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +#[cfg(unix)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + std::os::unix::fs::symlink(source, link).expect("create directory symlink"); +} + +#[cfg(windows)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + // Running this test locally may require Windows Developer Mode or an elevated process. + std::os::windows::fs::symlink_dir(source, link) + .expect("create directory symlink; enable Developer Mode or run the test elevated"); +} + pub trait TempDirExt { fn abs(&self) -> AbsolutePathBuf; } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 318c288dc6..3fa2136ecb 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::create_directory_symlink; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -8,6 +9,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::TempDir; @@ -143,6 +145,86 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut builder = test_codex() + .with_config(|config| { + config.cwd = config.cwd.join("logical-repo/workspace"); + }) + .with_workspace_setup(|cwd, _fs| async move { + // Construct two sibling repositories with the configured cwd as a + // directory symlink from the logical repository into the physical + // repository: + // + // test-root/ + // |-- logical-repo/ + // | |-- .git + // | |-- AGENTS.md ("logical parent doc") + // | `-- workspace ------------> physical-repo/workspace/ + // `-- physical-repo/ + // |-- .git + // |-- AGENTS.md ("physical parent doc") + // `-- workspace/ + // `-- AGENTS.md ("workspace doc") + // + // Discovery should walk the lexical path through logical-repo, + // while opening logical-repo/workspace/AGENTS.md still follows the + // symlink into physical-repo/workspace. + let logical_root = cwd.parent().expect("symlink should have a parent"); + let test_root = logical_root + .parent() + .expect("logical repository should have a parent"); + let physical_root = test_root.join("physical-repo"); + let physical_workspace = physical_root.join("workspace"); + + std::fs::create_dir_all(logical_root.as_path())?; + std::fs::write(logical_root.join(".git"), "")?; + std::fs::write(logical_root.join("AGENTS.md"), "logical parent doc")?; + + std::fs::create_dir_all(physical_workspace.as_path())?; + std::fs::write(physical_root.join(".git"), "")?; + std::fs::write(physical_root.join("AGENTS.md"), "physical parent doc")?; + std::fs::write(physical_workspace.join("AGENTS.md"), "workspace doc")?; + + create_directory_symlink(physical_workspace.as_path(), cwd.as_path()); + Ok(()) + }); + let test = builder.build(&server).await?; + let logical_root = test + .config + .cwd + .parent() + .expect("symlink should have a parent"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![ + logical_root.join("AGENTS.md"), + test.config.cwd.join("AGENTS.md") + ] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("logical parent doc")); + assert!(instructions.contains("workspace doc")); + assert!(!instructions.contains("physical parent doc")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { let server = start_mock_server().await; @@ -167,13 +249,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok::<(), anyhow::Error>(()) }); let test = builder.build_with_remote_env(&server).await?; - let project_agents = test - .fs() - .canonicalize( - &test.executor_environment().cwd().join("AGENTS.md"), - /*sandbox*/ None, - ) - .await?; + let project_agents = test.config.cwd.join("AGENTS.md"); let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); assert_eq!( From 769c231aa1d69e2482fda2a51444d7f7a2158e3a Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Thu, 4 Jun 2026 15:10:24 -0700 Subject: [PATCH 044/141] fix(app-server): expose remote MCP servers in plugin read (#26453) ## Why Remote plugin detail responses include MCP server metadata under `release.mcp_servers`, but Codex did not deserialize or propagate that field. As a result, `plugin/read` always returned an empty `mcpServers` list for remote plugins, so the plugin details pane omitted the MCP Servers section even when the remote plugin declares one. This affects uninstalled plugins as well: the remote detail API is the source of truth and returns MCP server keys without requiring a local plugin bundle. ## What changed - Deserialize MCP server entries from remote plugin detail responses. - Normalize their keys into a sorted, deduplicated list on `RemotePluginDetail`. - Return those keys from app-server `plugin/read` instead of hardcoding an empty list. - Add regression coverage proving an uninstalled remote plugin returns its MCP server names. ## Test plan - `just test -p codex-core-plugins` - `just test -p codex-app-server plugin_read` --- .../src/request_processors/plugins.rs | 2 +- .../app-server/tests/suite/v2/plugin_read.rs | 36 +++++++++++++------ codex-rs/core-plugins/src/remote.rs | 17 +++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 2335694856..3163532449 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -2026,7 +2026,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, - mcp_servers: Vec::new(), + mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index f78cd2a088..9e9e844228 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -122,7 +122,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( @@ -148,22 +148,31 @@ plugins = true let detail_body = r#"{ "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", + "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { - "display_name": "Linear", - "description": "Track work in Linear", + "version": "1.2.1", + "display_name": "Example Plugin", + "description": "Example plugin", "app_ids": [], "keywords": [], "interface": { - "short_description": "Plan and track work", + "short_description": "Example plugin", "capabilities": [], - "default_prompt": "Use the legacy Linear prompt", + "default_prompt": "Use the legacy example prompt", "default_prompts": [] }, - "skills": [] + "skills": [], + "mcp_servers": [ + { + "key": "example-server", + "metadata": { + "command": "example-mcp" + } + } + ] } }"#; let installed_body = r#"{ @@ -211,12 +220,15 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); - assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); + assert_eq!( + response.plugin.summary.id, + "example-plugin@openai-curated-remote" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") ); - assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); assert_eq!( @@ -226,7 +238,11 @@ plugins = true .interface .as_ref() .and_then(|interface| interface.default_prompt.clone()), - Some(vec!["Use the legacy Linear prompt".to_string()]) + Some(vec!["Use the legacy example prompt".to_string()]) + ); + assert_eq!( + response.plugin.mcp_servers, + vec!["example-server".to_string()] ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 045ad8e184..9b34fd293c 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,6 +167,7 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub mcp_servers: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -417,6 +418,13 @@ struct RemotePluginReleaseResponse { interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemotePluginMcpServerResponse { + key: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -948,6 +956,14 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut mcp_servers = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_servers.sort_unstable(); + mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, @@ -959,6 +975,7 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + mcp_servers, }) } From e695ec8ec612f61f6150299cc5c0ec39138dc902 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 15:40:16 -0700 Subject: [PATCH 045/141] Add skill for pushing CI configuration changes (#26473) ## Why Codex agents that modify GitHub Actions configuration need clear guidance when repository push protections require temporary approval. Without it, an agent may pursue an unavailable exemption or stop before checking whether the user already has access. ## What Add a `pushing-ci-changes` skill that explains the restriction, directs agents to attempt the push first, and tells them how to involve the user when approval is required. ## Validation Not run; this change only adds skill documentation. --- .codex/skills/pushing-ci-changes/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .codex/skills/pushing-ci-changes/SKILL.md diff --git a/.codex/skills/pushing-ci-changes/SKILL.md b/.codex/skills/pushing-ci-changes/SKILL.md new file mode 100644 index 0000000000..cb91bdd981 --- /dev/null +++ b/.codex/skills/pushing-ci-changes/SKILL.md @@ -0,0 +1,17 @@ +--- +name: pushing-ci-changes +description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions. +--- + +The Codex repository prevents anyone from uploading changes to its CI configuration unless they've +been granted a temporary role. + +In order to push changes to `.github/**/*.yml` and related files you'll need the user to read +go/workflow-approvals and request an approval through that flow. It is not possible for you to +make progress by requesting an exemption yourself. + +If you know you're about to push changes that would fail due to these restrictions you should still +try to push anyways to confirm the user's account doesn't already have the necessary approval. + +If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with +the user and ask them to return control to you once the approval has propagated to GitHub. From b9ff4509029b40a0c10964aa751a1a006ab46359 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 4 Jun 2026 16:42:27 -0700 Subject: [PATCH 046/141] [codex] Expose unavailable app templates in plugin detail (#26317) ## Summary - Adds `unavailable_app_templates` to the app-server protocol and generated schemas/types. - Parses plugin-service `release.unavailable_app_templates` in the remote plugin client. - Maps remote unavailable templates into app-server `PluginDetail`. - Defaults local plugins to an empty unavailable app template list. ## Validation - `just write-app-server-schema` - `cargo +1.95.0 fmt --manifest-path codex-rs/Cargo.toml --all --check` - `cargo +1.95.0 test --manifest-path codex-rs/Cargo.toml -p codex-app-server-protocol schema_fixtures` - `cargo +1.95.0 check --manifest-path codex-rs/Cargo.toml -p codex-app-server-protocol -p codex-core-plugins -p codex-app-server` - `git diff --check` Note: default `cargo check` uses rustc 1.89 locally and failed because dependencies require newer Rust, so validation was rerun with installed Rust 1.95. --- .../codex_app_server_protocol.schemas.json | 76 ++++++++++++++++++- .../codex_app_server_protocol.v2.schemas.json | 70 +++++++++++++++++ .../schema/json/v2/PluginReadResponse.json | 70 +++++++++++++++++ .../typescript/v2/AppTemplateSummary.ts | 6 ++ .../v2/AppTemplateUnavailableReason.ts | 5 ++ .../schema/typescript/v2/PluginDetail.ts | 3 +- .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/plugin.rs | 23 ++++++ codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/plugins.rs | 25 ++++++ .../app-server/tests/suite/v2/plugin_read.rs | 49 ++++++++++++ codex-rs/core-plugins/src/remote.rs | 55 ++++++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 13 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 81617aa93c..4802a52300 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3786,8 +3786,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -6278,6 +6278,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -12168,6 +12231,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/v2/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/v2/AppSummary" @@ -12216,6 +12285,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -19232,4 +19302,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 336350bcec..2b9e30bae7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -643,6 +643,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -8690,6 +8753,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -8738,6 +8807,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index c9fe43c34f..5a4c2e5b09 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "HookEventName": { "enum": [ "preToolUse", @@ -78,6 +141,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -126,6 +195,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts new file mode 100644 index 0000000000..dd5f76229f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; + +export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array, reason: AppTemplateUnavailableReason | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts new file mode 100644 index 0000000000..563056798e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 64836c87f7..cc2042dd5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,8 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { AppTemplateSummary } from "./AppTemplateSummary"; import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 48e1f9580b..b4c65946d5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -21,6 +21,8 @@ export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; export type { AppSummary } from "./AppSummary"; +export type { AppTemplateSummary } from "./AppTemplateSummary"; +export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 1b7e8ba8ff..6222eb6e73 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -642,9 +642,32 @@ pub struct PluginDetail { pub skills: Vec, pub hooks: Vec, pub apps: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[ts(export_to = "v2/")] +pub enum AppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppTemplateSummary { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2f3c87d868..0e94528a7a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -30,6 +30,8 @@ use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 3163532449..79791c142a 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; @@ -1062,6 +1063,7 @@ impl PluginRequestProcessor { }) .collect(), apps: app_summaries, + app_templates: Vec::new(), mcp_servers: outcome.plugin.mcp_server_names, } } @@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info( detail: RemoteCatalogPluginDetail, apps: Vec, ) -> PluginDetail { + let app_templates = detail + .app_templates + .into_iter() + .map(|template| AppTemplateSummary { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason.map(|reason| match reason { + RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => { + AppTemplateUnavailableReason::NotConfiguredForWorkspace + } + RemoteAppTemplateUnavailableReason::NoActiveWorkspace => { + AppTemplateUnavailableReason::NoActiveWorkspace + } + }), + }) + .collect(); + PluginDetail { marketplace_name: detail.marketplace_name, marketplace_path: None, @@ -2026,6 +2050,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, + app_templates, mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 9e9e844228..e675a6172a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,6 +17,8 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -427,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "app_templates": [ + { + "template_id": "templated_apps_GitHubEnterprise", + "name": "GitHub Enterprise", + "description": "Connect GitHub Enterprise", + "canonical_connector_id": "github_enterprise", + "logo_url": "https://example.com/ghe-light.png", + "logo_url_dark": "https://example.com/ghe-dark.png", + "materialized_app_ids": ["asdk_app_ghe"], + "reason": null + }, + { + "template_id": "templated_apps_Databricks", + "name": "Databricks", + "description": null, + "canonical_connector_id": null, + "logo_url": null, + "logo_url_dark": null, + "materialized_app_ids": [], + "reason": "NOT_CONFIGURED_FOR_WORKSPACE" + } + ], "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", @@ -564,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.skills[0].path, None); assert_eq!(response.plugin.skills[0].enabled, false); assert_eq!(response.plugin.apps.len(), 0); + assert_eq!( + response.plugin.app_templates, + vec![ + AppTemplateSummary { + template_id: "templated_apps_GitHubEnterprise".to_string(), + name: "GitHub Enterprise".to_string(), + description: Some("Connect GitHub Enterprise".to_string()), + canonical_connector_id: Some("github_enterprise".to_string()), + logo_url: Some("https://example.com/ghe-light.png".to_string()), + logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()), + materialized_app_ids: vec!["asdk_app_ghe".to_string()], + reason: None, + }, + AppTemplateSummary { + template_id: "templated_apps_Databricks".to_string(), + name: "Databricks".to_string(), + description: None, + canonical_connector_id: None, + logo_url: None, + logo_url_dark: None, + materialized_app_ids: Vec::new(), + reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace), + }, + ] + ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 9b34fd293c..7daf1da31f 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,9 +167,29 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAppTemplate { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemoteAppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSkill { pub name: String, @@ -413,6 +433,8 @@ struct RemotePluginReleaseResponse { app_ids: Vec, #[serde(default)] app_manifest: Option, + #[serde(default, alias = "unavailable_app_templates")] + app_templates: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, @@ -427,6 +449,24 @@ struct RemotePluginMcpServerResponse { key: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemoteAppTemplateResponse { + template_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + canonical_connector_id: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + logo_url_dark: Option, + #[serde(default)] + materialized_app_ids: Vec, + #[serde(default)] + reason: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, @@ -975,6 +1015,21 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + app_templates: plugin + .release + .app_templates + .into_iter() + .map(|template| RemoteAppTemplate { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason, + }) + .collect(), mcp_servers, }) } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index cfc9278ded..b058b47cca 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1429,6 +1429,7 @@ pub(super) fn plugins_test_detail( needs_auth: *needs_auth, }) .collect(), + app_templates: Vec::new(), mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), } } From 0b2e7b5eb1cfa74e5807a84b291e6c900eeb197d Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 4 Jun 2026 16:52:10 -0700 Subject: [PATCH 047/141] Improve Windows sandbox setup refresh diagnostics (#26471) ## Why Users have been seeing opaque Windows sandbox setup refresh failures such as `windows sandbox: spawn setup refresh`, including reports in #24391 and #21208. The setup refresh path already runs the Windows sandbox setup helper, but it was not using the same structured `setup_error.json` reporting path that elevated setup uses. As a result, when the helper exited non-zero, Codex only surfaced a generic refresh status instead of the helper's `SetupFailure` code and message. ## What changed - Clear stale `setup_error.json` before non-elevated setup refresh launches the helper. - When the refresh helper exits non-zero, read the helper-written report through the existing `report_helper_failure` path. - Keep a parent-side launch diagnostic for cases where the helper never starts, including the helper path, cwd, sandbox log path, and spawn error. - Clear the setup error report after a successful refresh. - Add regression coverage for report consumption and stale-report avoidance. ## Verification - `cargo test -p codex-windows-sandbox setup::tests::` --- codex-rs/windows-sandbox-rs/src/setup.rs | 110 +++++++++++++++--- .../windows-sandbox-rs/src/setup_error.rs | 2 +- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index c1881b55c8..428b5ff6a1 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -15,6 +15,7 @@ use crate::allow::compute_allow_paths_for_permissions; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::identity::sandbox_setup_is_complete; +use crate::logging::current_log_file_path; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; @@ -25,7 +26,6 @@ use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; use crate::setup_error::read_setup_error_report; use crate::ssh_config_dependencies::ssh_config_dependency_paths; -use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use base64::Engine; @@ -210,6 +210,18 @@ fn run_setup_refresh_inner( let json = serde_json::to_vec(&payload)?; let b64 = BASE64_STANDARD.encode(json); let exe = find_setup_exe(); + let sbx_dir = sandbox_dir(request.codex_home); + let log_path = current_log_file_path(&sbx_dir); + let cleared_report = match clear_setup_error_report(request.codex_home) { + Ok(()) => true, + Err(err) => { + log_note( + &format!("setup refresh: failed to clear setup_error.json before launch: {err}"), + Some(&sbx_dir), + ); + false + } + }; // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. let mut cmd = Command::new(&exe); cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); @@ -221,24 +233,34 @@ fn run_setup_refresh_inner( cwd.display(), b64.len() ), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), ); - let status = cmd - .status() - .map_err(|e| { - log_note( - &format!("setup refresh: failed to spawn {}: {e}", exe.display()), - Some(&sandbox_dir(request.codex_home)), - ); - e - }) - .context("spawn setup refresh")?; + let status = cmd.status().map_err(|err| { + let message = format!( + "setup refresh failed to launch helper: helper={}, cwd={}, log={}, error={err}", + exe.display(), + cwd.display(), + log_path.display() + ); + log_note(&format!("setup refresh: {message}"), Some(&sbx_dir)); + failure(SetupErrorCode::OrchestratorHelperLaunchFailed, message) + })?; if !status.success() { log_note( &format!("setup refresh: exited with status {status:?}"), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), + ); + return Err(report_helper_failure( + request.codex_home, + cleared_report, + status.code(), + )); + } + if let Err(err) = clear_setup_error_report(request.codex_home) { + log_note( + &format!("setup refresh: failed to clear setup_error.json after success: {err}"), + Some(&sbx_dir), ); - return Err(anyhow!("setup refresh failed with status {status}")); } Ok(()) } @@ -1087,7 +1109,9 @@ mod tests { use crate::helper_materialization::helper_bin_dir; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; + use crate::setup_error::SetupErrorReport; use crate::setup_error::extract_failure; + use crate::setup_error::write_setup_error_report; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1146,6 +1170,64 @@ mod tests { ) } + #[test] + fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "firewall policy unavailable".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ true, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + "firewall policy unavailable", + ), + failure + ); + } + + #[test] + fn report_helper_failure_ignores_setup_error_report_when_clear_failed() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "stale report".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ false, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::OrchestratorHelperExitNonzero, + "setup helper exited with status Some(1)", + ), + failure + ); + } + #[test] fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 0f759ef879..d9104602bc 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -117,7 +117,7 @@ pub struct SetupErrorReport { pub message: String, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct SetupFailure { pub code: SetupErrorCode, pub message: String, From 72d0bfb6ba4b59ad78fbe012052a06a2c0874fc3 Mon Sep 17 00:00:00 2001 From: beggers-openai Date: Thu, 4 Jun 2026 17:47:58 -0700 Subject: [PATCH 048/141] Pull plugin service less frequently (#26431) # Summary Reduce download traffic to `github.com/openai/plugins` while continuing to check for updates on every Codex startup. # Root cause The startup sync replaced the local repository with a fresh shallow clone whenever the remote revision changed. At Codex's global scale, repeatedly downloading the repository created excessive GitHub traffic. # Changes - Run `git ls-remote` on each startup to read the remote HEAD SHA. - Skip all repository downloads when the local and remote SHAs match. - Update existing checkouts with an exact-SHA shallow `git fetch`, followed by reset and clean. - Bootstrap new installations with `git init` plus the same shallow fetch, rather than cloning. - Keep the existing file lock so concurrent Codex processes serialize updates and do not duplicate fetches. - Preserve the existing GitHub HTTP and export archive fallback behavior. # Impact Each startup makes one lightweight remote HEAD check. Repository objects are downloaded only when the revision changes, and existing Git objects are reused during updates. # Validation - `just test -p codex-core-plugins startup_sync` (15 tests passed) - `just test -p codex-core-plugins` (201 tests passed) - `just clippy -p codex-core-plugins` (passes with one pre-existing `large_enum_variant` warning) - Production app-server smoke test against GitHub: - Fresh home: `ls-remote`, `git init`, one exact-SHA shallow fetch - Unchanged restart: `ls-remote` and local `rev-parse` only; no fetch or clone - Bench smoke passed --- codex-rs/core-plugins/src/startup_sync.rs | 141 ++++++- .../core-plugins/src/startup_sync_tests.rs | 349 +++++++++++++++--- 2 files changed, 418 insertions(+), 72 deletions(-) diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index f03aff93f6..c5965f2212 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,8 +23,11 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; +const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_SYNC_LOCK_FILE: &str = ".tmp/plugins.sync.lock"; const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -78,6 +82,8 @@ fn sync_openai_plugins_repo_with_transport_overrides( api_base_url: &str, backup_archive_api_url: &str, ) -> Result { + let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); @@ -135,6 +141,22 @@ fn sync_openai_plugins_repo_with_transport_overrides( } } +fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result { + let lock_path = codex_home.join(CURATED_PLUGINS_SYNC_LOCK_FILE); + std::fs::create_dir_all(codex_home.join(".tmp")) + .map_err(|err| format!("failed to create curated plugins sync directory: {err}"))?; + let lock_file = File::options() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open curated plugins sync lock: {err}"))?; + lock_file + .lock() + .map_err(|err| format!("failed to lock curated plugins sync: {err}"))?; + Ok(lock_file) +} + fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -146,23 +168,30 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let clone_output = run_git_command_with_timeout( - Command::new(git_binary) - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("clone") - .arg("--depth") - .arg("1") - .arg("https://github.com/openai/plugins.git") - .arg(staged_repo_dir.path()), - "git clone curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, + run_git_in_repo( + staged_repo_dir.path(), + git_binary, + &["init"], + "git init curated plugins repo", )?; - ensure_git_success(&clone_output, "git clone curated plugins repo")?; - let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; - if cloned_sha != remote_sha { + if repo_path.join(".git").is_dir() { + fetch_curated_plugins_commit(&repo_path, &remote_sha, git_binary)?; + fetch_curated_plugins_commit_from_source( + staged_repo_dir.path(), + &repo_path, + CURATED_PLUGINS_FETCH_REF, + git_binary, + )?; + } else { + fetch_curated_plugins_commit(staged_repo_dir.path(), &remote_sha, git_binary)?; + } + + reset_curated_plugins_checkout(staged_repo_dir.path(), git_binary)?; + let fetched_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if fetched_sha != remote_sha { return Err(format!( - "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + "curated plugins fetch HEAD mismatch: expected {remote_sha}, got {fetched_sha}" )); } @@ -172,6 +201,90 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu Ok(remote_sha) } +fn fetch_curated_plugins_commit( + repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + OPENAI_PLUGINS_GIT_URL.as_ref(), + remote_sha, + git_binary, + "git fetch curated plugins repo", + ) +} + +fn fetch_curated_plugins_commit_from_source( + repo_path: &Path, + source_repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + source_repo_path, + remote_sha, + git_binary, + "git copy fetched curated plugins commit", + ) +} + +fn fetch_curated_plugins_commit_from( + repo_path: &Path, + source: &Path, + source_revision: &str, + git_binary: &str, + context: &str, +) -> Result<(), String> { + let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}"); + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(["fetch", "--depth", "1", "--no-tags"]) + .arg(source) + .arg(fetch_refspec), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + +fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> { + run_git_in_repo( + repo_path, + git_binary, + &["reset", "--hard", CURATED_PLUGINS_FETCH_REF], + "git reset curated plugins repo", + )?; + run_git_in_repo( + repo_path, + git_binary, + &["clean", "-fdx"], + "git clean curated plugins repo", + ) +} + +fn run_git_in_repo( + repo_path: &Path, + git_binary: &str, + args: &[&str], + context: &str, +) -> Result<(), String> { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(args), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index a9388e3fce..f73e4ad9d1 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -3,6 +3,8 @@ use pretty_assertions::assert_eq; use std::io::Write; use std::path::Path; use std::path::PathBuf; +#[cfg(unix)] +use std::sync::Barrier; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -95,6 +97,22 @@ fn write_executable_script(path: &Path, contents: &str) { } } +#[cfg(unix)] +fn run_git(repo: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { Mock::given(method("GET")) .and(path("/repos/openai/plugins")) @@ -253,30 +271,42 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_prefers_git_when_available() { +fn concurrent_syncs_serialize_fetches_without_skipping_remote_checks() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-") .tempdir() .expect("tempdir"); let git_path = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); let sha = "0123456789abcdef0123456789abcdef01234567"; write_executable_script( &git_path, &format!( r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' if [ "$1" = "ls-remote" ]; then + sleep 1 printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" - cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/.agents/plugins" "$2/plugins/gmail/.codex-plugin" + cat > "$2/.agents/plugins/marketplace.json" <<'EOF' {{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} EOF - printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + printf '%s\n' '{{"name":"gmail"}}' > "$2/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then exit 0 fi if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then @@ -285,23 +315,55 @@ if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then fi echo "unexpected git invocation: $@" >&2 exit 1 -"# +"#, + invocation_log.display() ), ); - let synced_sha = sync_openai_plugins_repo_with_transport_overrides( - tmp.path(), - git_path.to_str().expect("utf8 path"), - "http://127.0.0.1:9", - "http://127.0.0.1:9/backend-api/plugins/export/curated", - ) - .expect("git sync should succeed"); + let barrier = Barrier::new(2); + let results = std::thread::scope(|scope| { + let run_sync = || { + barrier.wait(); + sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + }; + let first = scope.spawn(run_sync); + let second = scope.spawn(run_sync); + [ + first.join().expect("first sync thread"), + second.join().expect("second sync thread"), + ] + }); - assert_eq!(synced_sha, sha); + assert_eq!(results, [Ok(sha.to_string()), Ok(sha.to_string())]); let repo_path = curated_plugins_repo_path(tmp.path()); assert!(repo_path.join(".git").is_dir()); assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + let invocations = std::fs::read_to_string(invocation_log).expect("read invocation log"); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.starts_with("ls-remote ")) + .count(), + 2 + ); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + .count(), + 1 + ); + assert!( + !invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); } #[cfg(unix)] @@ -328,36 +390,20 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { ) .expect("write plugin manifest"); - let init_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("init") - .status() - .expect("run git init"); - assert!(init_status.success()); - - let add_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("add") - .arg(".") - .status() - .expect("run git add"); - assert!(add_status.success()); - - let commit_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("-c") - .arg("user.name=Codex Test") - .arg("-c") - .arg("user.email=codex@example.com") - .arg("commit") - .arg("-m") - .arg("init") - .status() - .expect("run git commit"); - assert!(commit_status.success()); + run_git(&work_repo, &["init"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "init", + ], + ); std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) .expect("create remote parent"); @@ -370,14 +416,7 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .expect("run git clone --bare"); assert!(clone_status.success()); - let sha_output = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("rev-parse") - .arg("HEAD") - .output() - .expect("run git rev-parse"); - assert!(sha_output.status.success()); + let sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); let sha = String::from_utf8_lossy(&sha_output.stdout) .trim() .to_string(); @@ -397,10 +436,12 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .tempdir() .expect("tempdir"); let git_wrapper = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); write_executable_script( &git_wrapper, &format!( - "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + invocation_log.display(), git_config_path.display() ), ); @@ -416,6 +457,125 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { Some(sha.as_str()) ); assert!(!has_plugins_clone_dirs(tmp.path())); + + let first_sync_invocation_count = std::fs::read_to_string(&invocation_log) + .expect("read first sync invocations") + .lines() + .count(); + let first_sync_invocations = + std::fs::read_to_string(&invocation_log).expect("read first sync invocations"); + assert!( + first_sync_invocations + .lines() + .any(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + ); + assert!( + !first_sync_invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + write_openai_curated_marketplace(&work_repo, &["gmail", "linear"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "update", + ], + ); + let branch_output = run_git(&work_repo, &["symbolic-ref", "--short", "HEAD"]); + let branch = String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string(); + let remote_repo = remote_repo.to_str().expect("utf8 remote repo"); + let push_ref = format!("HEAD:refs/heads/{branch}"); + run_git(&work_repo, &["push", remote_repo, &push_ref]); + let updated_sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); + let updated_sha = String::from_utf8_lossy(&updated_sha_output.stdout) + .trim() + .to_string(); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("incremental git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + assert!( + curated_plugins_repo_path(tmp.path()) + .join("plugins/linear/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(updated_sha.as_str()) + ); + assert!( + !curated_plugins_repo_path(tmp.path()) + .join(".git/objects/info/alternates") + .exists() + ); + let invocation_log_contents = + std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let incremental_sync_invocations = invocation_log_contents + .lines() + .skip(first_sync_invocation_count) + .collect::>(); + let curated_repo_path = curated_plugins_repo_path(tmp.path()); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} fetch ", curated_repo_path.display())) + && invocation.contains(" https://github.com/openai/plugins.git ") + && invocation.contains(updated_sha.as_str()) + && invocation.ends_with(CURATED_PLUGINS_FETCH_REF) + })); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.contains(" fetch --depth 1 --no-tags ") + && invocation.contains(&format!(" {} ", curated_repo_path.display())) + && invocation.ends_with(&format!( + "{CURATED_PLUGINS_FETCH_REF}:{CURATED_PLUGINS_FETCH_REF}" + )) + })); + assert!( + incremental_sync_invocations + .iter() + .any(|invocation| invocation.ends_with(" init")) + ); + assert!( + !incremental_sync_invocations + .iter() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + assert!(!incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} reset ", curated_repo_path.display())) + || invocation.starts_with(&format!("-C {} clean ", curated_repo_path.display())) + })); + assert!(!has_plugins_clone_dirs(tmp.path())); + + let unchanged_sync_invocation_count = invocation_log_contents.lines().count(); + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("unchanged git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let unchanged_sync_invocations = invocation_log + .lines() + .skip(unchanged_sync_invocation_count) + .collect::>(); + assert!( + unchanged_sync_invocations + .iter() + .any(|invocation| invocation.starts_with("ls-remote ")) + ); + assert!( + !unchanged_sync_invocations + .iter() + .any(|invocation| invocation.contains(" fetch ")) + ); } #[tokio::test] @@ -482,7 +642,7 @@ exit 1 #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_fetch_failure() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-partial-fail-") @@ -499,9 +659,11 @@ if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then echo "fatal: early EOF" >&2 exit 128 fi @@ -518,6 +680,77 @@ exit 1 assert!(!has_plugins_clone_dirs(tmp.path())); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_preserves_existing_snapshot_on_validation_failure() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&repo_path, &["gmail"]); + std::fs::create_dir_all(repo_path.join(".git")).expect("create git dir"); + write_curated_plugin_sha(tmp.path()); + + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-invalid-update-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let remote_sha = "fedcba9876543210fedcba9876543210fedcba98"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{remote_sha}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{TEST_CURATED_PLUGIN_SHA}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/plugins/linear/.codex-plugin" + printf '%s\n' '{{"name":"linear"}}' > "$2/plugins/linear/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{remote_sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"#, + repo_path.display(), + repo_path.display(), + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("invalid staged checkout should fail"); + + assert!(err.contains("curated plugins archive missing marketplace manifest")); + assert_curated_gmail_repo(&repo_path); + assert!(!repo_path.join("plugins/linear").exists()); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { let tmp = tempdir().expect("tempdir"); From 4be1a168fc7ed3313ddc62031da036f0c50209b7 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Thu, 4 Jun 2026 17:51:13 -0700 Subject: [PATCH 049/141] ci: test windows cross build (#25000) We cross build when using bazel for windows. This causes a couple hiccups in that v8 does a mksnapshot step that is expecting to snapshot on the host arch which wasn't matching when we were doing the crossbuild. This was causing segfault failiures when starting up codemode from a cross built artifact. This changes things such that we cross build the library and then run and link a snapshot on the host machine/arch which is windows. This gives us a functional snapshot and library that can start code-mode on windows. This fixes the build and then fixes two test regressions we had. --- .bazelrc | 14 ++++--- .github/workflows/bazel.yml | 24 +++++------ codex-rs/core/tests/suite/code_mode.rs | 16 +++++-- patches/v8_bazel_rules.patch | 58 +++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/.bazelrc b/.bazelrc index e39a3aff22..c8b8682c4b 100644 --- a/.bazelrc +++ b/.bazelrc @@ -105,9 +105,9 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel -# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests -# are not stable in that setup yet. Keep running the rest of the Rust -# integration suites through the workspace-root launcher. +# Keep code-mode integration cases out of ordinary Bazel legs. The +# Windows-cross config below re-enables them after generating its Windows V8 +# snapshot on the Windows runner. common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. @@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true common:ci-windows-cross --host_platform=//:rbe common:ci-windows-cross --strategy=TestRunner=local +# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be +# generated by a Windows mksnapshot binary rather than the Linux RBE host tool. +common:ci-windows-cross --strategy=V8Mksnapshot=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 # Native Windows CI still covers the PowerShell tests. The cross-built gnullvm # binaries currently hang in PowerShell AST parser tests when those binaries are -# run on the Windows runner. -common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell +# run on the Windows runner. Keep V8-backed code-mode tests enabled except for +# the hidden dynamic-tool callback test, which currently times out on Windows. +common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index c6dfd60231..bddbb5adc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -20,8 +20,9 @@ jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge # pushes to main also run the native Windows test job for broader Windows - # signal without putting PR latency back on the critical path. Cargo CI - # owns V8/code-mode test coverage for now. + # signal without putting PR latency back on the critical path. When + # authenticated RBE is available, the Windows-cross shards exercise the + # source-built V8/code-mode targets. timeout-minutes: 30 strategy: fail-fast: false @@ -91,9 +92,9 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # V8-backed code-mode tests are covered by Cargo CI. Bazel CI - # cross-compiles in several legs, and those tests are not stable in - # that setup yet. + # Keep V8-backed code-mode tests out of the ordinary macOS/Linux + # legs; authenticated Windows-cross shards below exercise the + # source-built gnullvm V8 path. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) @@ -135,9 +136,9 @@ jobs: key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} test-windows-shard: - # Split the Windows Bazel test leg across separate Windows - # hosts. Each shard still uses Linux RBE for build actions, but the test - # execution itself happens on its own Windows runner. + # Split the Windows Bazel test leg across separate Windows hosts. Jobs with + # BuildBuddy credentials use Linux RBE for build actions; test execution + # remains on a Windows runner. timeout-minutes: 30 strategy: fail-fast: false @@ -183,7 +184,7 @@ jobs: run: | set -euo pipefail - bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ | LC_ALL=C sort @@ -289,9 +290,8 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # Keep this aligned with the main Bazel job. The native Windows - # job preserves broad post-merge coverage, but code-mode/V8 tests - # are covered by Cargo CI rather than Bazel for now. + # Keep this job broad and cheap; authenticated Windows-cross jobs + # add source-built V8-backed code-mode coverage. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index ffe23bff56..bc311d4245 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -375,7 +375,11 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string() + ] ); Ok(()) @@ -457,7 +461,12 @@ if (!tool) { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string(), + "image_generation".to_string() + ] ); let exec_description = first_body @@ -2898,6 +2907,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "escape", "exit", "eval", + "generatedImage", "globalThis", "image", "isFinite", @@ -2953,7 +2963,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file.\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 1c49d06b09..97e07d9d47 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -106,6 +106,55 @@ index 9648e4a..88efd41 100644 }) + select({ ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], +@@ -459,6 +488,11 @@ + def _mksnapshot(ctx): + prefix = ctx.attr.prefix + suffix = ctx.attr.suffix ++ # Windows cross-builds use Linux for the exec configuration, but the ++ # snapshot generator must match the target ABI and run on the Windows ++ # runner. Action strategies only choose where an action runs; they cannot ++ # change this executable from the exec to the target configuration. ++ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool + outs = [ + ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), + ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), +@@ -476,7 +510,7 @@ + outs[1].path, + ] + ctx.attr.args, +- executable = ctx.executable.tool, ++ executable = tool, + progress_message = "Running mksnapshot", + ) + return [DefaultInfo(files = depset(outs))] +@@ -491,6 +525,12 @@ + executable = True, + cfg = "exec", + ), ++ "target_tool": attr.label( ++ mandatory = True, ++ allow_files = True, ++ executable = True, ++ cfg = "target", ++ ), + "target_os": attr.string(mandatory = True), + "prefix": attr.string(mandatory = True), + "suffix": attr.string(mandatory = True), +@@ -504,6 +544,7 @@ + args = args, + prefix = "noicu", + tool = ":noicu/mksnapshot" + suffix, ++ target_tool = ":noicu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", +@@ -516,6 +557,7 @@ + args = args, + prefix = "icu", + tool = ":icu/mksnapshot" + suffix, ++ target_tool = ":icu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel index 421ebcd..52283ea 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel @@ -309,7 +358,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4772,9 +4784,15 @@ v8_binary( +@@ -4772,9 +4784,20 @@ v8_binary( ":icu/generated_torque_initializers", ":icu/v8_initializers_files", ], @@ -317,11 +366,16 @@ index 421ebcd..52283ea 100644 + # external-reference helpers together while producing the snapshot, the + # final embedder binary may not fold the same pair and startup + # deserialization will reject the snapshot. ++ # GN also increases the x64 Windows stack reserve because constructing the ++ # snapshot overflows the linker's 1 MiB default. linkopts = select({ "@v8//bazel/config:is_android": ["-llog"], - "//conditions:default": [], + "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], -+ "@v8//bazel/config:is_windows": ["/OPT:NOICF"], ++ "@v8//bazel/config:is_windows": [ ++ "-Wl,/OPT:NOICF", ++ "-Wl,/STACK:2097152", ++ ], + "//conditions:default": ["-Wl,--icf=none"], }), noicu_deps = [":v8_libshared_noicu"], From ecae41274091252f454eb5ed0507036ed4e6fc19 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Thu, 4 Jun 2026 20:58:14 -0400 Subject: [PATCH 050/141] [codex] Emit sandbox outcome telemetry event (#25955) ## Summary Adds a dedicated `codex.sandbox_outcome` telemetry event so we can query sandbox edge outcomes without threading sandbox metadata through tool-result output types. This is meant to make sandbox failures and approved escalation retries visible in OTEL while keeping the existing `codex.tool_result` event shape focused on tool completion data. ## What changed - Adds `SessionTelemetry::sandbox_outcome(...)`, which emits `codex.sandbox_outcome` as both a log and trace event. - Records the tool name, call id, sandbox outcome, initial attempt duration, and escalated attempt duration when a retry runs. - Emits `denied` when the sandbox blocks execution and no retry is run. - Emits `timed_out` and `signal` when those sandbox errors surface from tool execution. - Emits `escalated` when the initial sandboxed attempt fails and the approved unsandboxed retry succeeds. - Adds OTEL coverage for the new event payload, including timing fields. ## Validation - `RUST_MIN_STACK=8388608 just test -p codex-core sandbox_outcome_event_records_outcome handle_sandbox_error_user_approves_retry_records_tool_decision` - `just test -p codex-otel otel_export_routing_policy_routes_tool_result_log_and_trace_events runtime_metrics_summary_collects_tool_api_and_streaming_metrics` - `just fix -p codex-core` - `just fix -p codex-otel` --- codex-rs/core/src/tools/orchestrator.rs | 86 +++++++++++++++++-- codex-rs/core/tests/suite/otel.rs | 69 +++++++++++++++ codex-rs/otel/src/events/session_telemetry.rs | 31 +++++++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 235f8c4e96..77cbffde32 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; +use std::time::Instant; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -256,6 +257,7 @@ impl ToolOrchestrator { network_denial_cancellation_token: None, }; + let initial_attempt_start = Instant::now(); let (first_result, first_deferred_network_approval) = Self::run_attempt( tool, req, @@ -264,6 +266,7 @@ impl ToolOrchestrator { managed_network_active, ) .await; + let initial_duration = initial_attempt_start.elapsed(); match first_result { Ok(out) => { // We have a successful initial result @@ -284,12 +287,26 @@ impl ToolOrchestrator { None }; if network_policy_decision.is_some() && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, }))); } if !tool.escalate_on_failure() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -312,6 +329,13 @@ impl ToolOrchestrator { ExecApprovalRequirement::NeedsApproval { .. } ); if !allow_on_request_network_prompt { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -319,6 +343,13 @@ impl ToolOrchestrator { } } if !unsandboxed_allowed && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -400,15 +431,51 @@ impl ToolOrchestrator { }; // Second attempt. + let escalated_attempt_start = Instant::now(); let (retry_result, retry_deferred_network_approval) = Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active) .await; - retry_result.map(|output| OrchestratorRunResult { - output, - deferred_network_approval: retry_deferred_network_approval, - }) + let escalated_duration = escalated_attempt_start.elapsed(); + match retry_result { + Ok(output) => { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "escalated", + initial_duration, + Some(escalated_duration), + ); + Ok(OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + Some(escalated_duration), + ); + } + Err(err) + } + } + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + /*escalated_duration*/ None, + ); + } + Err(err) } - Err(err) => Err(err), } } @@ -509,6 +576,15 @@ impl ToolOrchestrator { } } +fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> { + match err { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"), + ToolError::Rejected(_) | ToolError::Codex(_) => None, + } +} + fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { // Keep approval reason terse and stable for UX/tests, but accept the // output so we can evolve heuristics later without touching call sites. diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 7f5fa260cd..d6c7577a7f 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,11 +1,15 @@ use codex_core::config::Constrained; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -27,6 +31,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use std::sync::Mutex; +use std::time::Duration; use tracing::Level; use tracing_test::traced_test; @@ -1145,6 +1150,70 @@ fn tool_decision_assertion<'a>( } } +fn sandbox_outcome_assertion<'a>( + call_id: &'a str, + expected_outcome: &'a str, +) -> impl Fn(&[&str]) -> Result<(), String> + 'a { + let call_id = call_id.to_string(); + let expected_outcome = expected_outcome.to_string(); + + move |lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.sandbox_outcome") + && line.contains(&format!("call_id={call_id}")) + }) + .ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?; + + let lower = line.to_lowercase(); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); + } + if !lower.contains(&format!("outcome={expected_outcome}")) { + return Err(format!("unexpected sandbox outcome for {call_id}")); + } + if !lower.contains("initial_duration_ms=12") { + return Err("missing initial_duration_ms field".to_string()); + } + if !lower.contains("escalated_duration_ms=34") { + return Err("missing escalated_duration_ms field".to_string()); + } + + Ok(()) + } +} + +#[test] +#[traced_test] +fn sandbox_outcome_event_records_outcome() { + let telemetry = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.5", + "gpt-5.5", + /*account_id*/ None, + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "Codex_Desktop".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ); + + telemetry.sandbox_outcome( + "shell_command", + "sandbox-outcome-call", + "escalated", + Duration::from_millis(/*millis*/ 12), + Some(Duration::from_millis(/*millis*/ 34)), + ); + + logs_assert(sandbox_outcome_assertion( + "sandbox-outcome-call", + "escalated", + )); +} + #[tokio::test] #[traced_test] async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index f4534a0791..0003cb04a5 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -998,6 +998,37 @@ impl SessionTelemetry { ); } + pub fn sandbox_outcome( + &self, + tool_name: &str, + call_id: &str, + outcome: &str, + initial_duration: Duration, + escalated_duration: Option, + ) { + let initial_duration_ms = initial_duration.as_millis().min(i64::MAX as u128) as i64; + let escalated_duration_ms = + escalated_duration.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64); + log_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + trace_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, From e0096db6dc8a2f89394986123802a676a1be20c5 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 4 Jun 2026 18:49:51 -0700 Subject: [PATCH 051/141] [codex] Add use_responses_lite 'override' logic (#26487) ## Summary - add a defaulted `ModelInfo.use_responses_lite` catalog field - support serializing `reasoning.context` while preserving the existing effort and summary path - has not been turned on for any models yet I've added an override to parallel tools if responses_lite is on. I've also forced persistent reasoning when using responses_lite. It would be ideal if we could centralize all the responses_lite plumbing, but I think this is best for now to keep the plumbing & diffs small. ## Testing - `cargo test -p codex-protocol model_info_defaults_availability_nux_to_none_when_omitted` - `RUST_MIN_STACK=8388608 cargo test -p codex-core responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls` - `RUST_MIN_STACK=8388608 cargo test -p codex-core configured_reasoning_summary_is_sent` - `cargo check -p codex-core --tests` - `RUST_MIN_STACK=8388608 cargo clippy -p codex-core --tests` (passes with pre-existing warnings in `codex-code-mode` and `codex-core-plugins`) --- .../app-server/tests/common/models_cache.rs | 1 + codex-rs/codex-api/src/common.rs | 10 +++ codex-rs/codex-api/src/lib.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/client.rs | 9 ++- codex-rs/core/tests/suite/auto_review.rs | 1 + codex-rs/core/tests/suite/client.rs | 65 +++++++++++++++++++ codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/models-manager/src/model_info.rs | 1 + codex-rs/protocol/src/openai_models.rs | 4 ++ codex-rs/tools/src/tool_config_tests.rs | 1 + 17 files changed, 104 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 127c14bbb4..8233b1b296 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 50ac2685b4..12f8cc55da 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -110,12 +110,22 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningContext { + Auto, + CurrentTurn, + AllTurns, +} + #[derive(Debug, Serialize, Clone, PartialEq)] pub struct Reasoning { #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Serialize, Default, Clone, PartialEq)] diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 8efebf076c..d2d7679406 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -30,6 +30,7 @@ pub use crate::common::OpenAiVerbosity; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; +pub use crate::common::ReasoningContext; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9ff273211f..4ee6069898 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9c133d85e4..ee8ea3619f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory; use codex_api::RealtimeCallClient as ApiRealtimeCallClient; use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig; use codex_api::Reasoning; +use codex_api::ReasoningContext; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseCreateWsRequest; @@ -609,6 +610,7 @@ impl ModelClient { reasoning: effort.map(|effort| Reasoning { effort: Some(effort), summary: None, + context: None, }), }; @@ -727,6 +729,11 @@ impl ModelClient { } else { Some(summary) }, + // When Responses Lite is disabled, omit context so Responses uses the default, + // which is currently `current_turn`. + context: model_info + .use_responses_lite + .then_some(ReasoningContext::AllTurns), }) } else { None @@ -775,7 +782,7 @@ impl ModelClient { input, tools, tool_choice: "auto".to_string(), - parallel_tool_calls: prompt.parallel_tool_calls, + parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite, reasoning, store: provider.is_azure_responses_endpoint(), stream: true, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 570f97008f..64ae10d688 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -231,6 +231,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index ad8ff17cdc..72e415e2be 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1872,6 +1872,71 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|value| value.as_str()), Some("concise") ); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")), + None + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + model.use_responses_lite = true; + model.supports_parallel_tool_calls = true; + + let TestCodex { codex, .. } = test_codex() + .with_model("gpt-5.4") + .with_config(move |config| { + config.model_catalog = Some(model_catalog); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(|value| value.as_str()), + Some("all_turns") + ); + pretty_assertions::assert_eq!(request_body.get("parallel_tool_calls"), Some(&json!(false))); Ok(()) } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 111ce50436..a80c43660c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -112,6 +112,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -932,6 +933,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 34fc98b59f..d27c43cfc6 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -370,6 +370,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cd264c0734..bf411d9cd1 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -592,6 +592,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -705,6 +706,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 608558d365..0ed39216d0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -478,6 +478,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -730,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -1216,6 +1218,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 36ea7d4b2c..552c4ed529 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1349,6 +1349,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 3b581c9051..ba29b4f00c 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -60,6 +60,7 @@ fn test_model_info( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 2e34475387..19758592c3 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1355,6 +1355,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 58137a3f50..81a5c6e5ed 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -99,6 +99,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4c04edbb56..1553ef3435 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -404,6 +404,8 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + #[serde(default)] + pub use_responses_lite: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, #[serde( @@ -674,6 +676,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -936,6 +939,7 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert!(!model.use_responses_lite); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 1b4d789394..0e3917d697 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -44,6 +44,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { input_modalities: codex_protocol::openai_models::default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, From 4de7a2b9d8eae19e00ca7f744647fa1aabdc204f Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 19:31:06 -0700 Subject: [PATCH 052/141] fix(rmcp): refresh expired OAuth tokens before startup (#26482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Codex persists OAuth expiry as an absolute `expires_at`, then reconstructs RMCP’s relative `expires_in` when credentials are loaded. For an already-expired token, Codex reconstructed `expires_in` as missing. [RMCP 0.15 treated a missing `expires_in` as zero when a refresh token was present](https://github.com/modelcontextprotocol/rust-sdk/blob/9cfc905a9ef17c8bba6748dc0a9bdd2452681733/crates/rmcp/src/transport/auth.rs#L704-L723), so this still triggered a refresh. [RMCP 1.7 treats missing expiry information as unknown and uses the access token as-is](https://github.com/modelcontextprotocol/rust-sdk/blob/3529c3675ff64db805bd947ca6ece6090809e43d/crates/rmcp/src/transport/auth.rs#L1233-L1265), causing the stale token to be sent during `initialize`. ## What changed - Represent a known-expired persisted token as `expires_in = 0`, preserving `None` for genuinely unknown expiry. - Add Streamable HTTP coverage requiring the token to refresh before the startup handshake. ## Validation - The new regression test fails on RMCP 1.7 before the fix and passes afterward. - The same scenario passes on the commit immediately before the RMCP 1.7 update, using RMCP 0.15. - `just test -p codex-rmcp-client` (63 passed). --- codex-rs/Cargo.lock | 1 + codex-rs/rmcp-client/Cargo.toml | 1 + codex-rs/rmcp-client/src/oauth.rs | 12 +- .../tests/streamable_http_oauth_startup.rs | 155 ++++++++++++++++++ .../tests/streamable_http_test_support.rs | 9 +- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e79865d43..6da7688435 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3597,6 +3597,7 @@ dependencies = [ "urlencoding", "webbrowser", "which 8.0.0", + "wiremock", ] [[package]] diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index e3417de70e..1006ad74e8 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -70,6 +70,7 @@ codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] keyring = { workspace = true, features = ["linux-native-async-persistent"] } diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index e23eee84be..c348460795 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -113,7 +113,13 @@ fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) { tokens.token_response.0.set_expires_in(Some(&duration)); } None => { - tokens.token_response.0.set_expires_in(None); + // RMCP treats a missing expiry as unknown and uses the access token + // as-is. Treat a known-expired timestamp as an explicit zero so + // startup refreshes the token before the first request. + tokens + .token_response + .0 + .set_expires_in(Some(&Duration::ZERO)); } } } @@ -830,7 +836,7 @@ mod tests { } #[test] - fn refresh_expires_in_from_timestamp_clears_expired_tokens() { + fn refresh_expires_in_from_timestamp_marks_expired_tokens() { let mut tokens = sample_tokens(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -843,7 +849,7 @@ mod tests { super::refresh_expires_in_from_timestamp(&mut tokens); - assert!(tokens.token_response.0.expires_in().is_none()); + assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO)); } fn assert_tokens_match_without_expiry( diff --git a/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs new file mode 100644 index 0000000000..1c18f2c98b --- /dev/null +++ b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs @@ -0,0 +1,155 @@ +mod streamable_http_test_support; + +use std::time::Duration; + +use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::Environment; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::StoredOAuthTokens; +use codex_rmcp_client::WrappedOAuthTokenResponse; +use codex_rmcp_client::save_oauth_tokens; +use oauth2::AccessToken; +use oauth2::RefreshToken; +use oauth2::basic::BasicTokenType; +use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::process::Command; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_string_contains; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use streamable_http_test_support::initialize_client; + +const SERVER_NAME: &str = "test-streamable-http-oauth-startup"; +const EXPIRED_ACCESS_TOKEN: &str = "expired-access-token"; +const REFRESH_TOKEN: &str = "valid-refresh-token"; +const REFRESHED_ACCESS_TOKEN: &str = "refreshed-access-token"; +const CHILD_SERVER_URL_ENV: &str = "MCP_TEST_OAUTH_STARTUP_SERVER_URL"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn refreshes_expired_persisted_token_before_initialize() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), + "token_endpoint": format!("{}/oauth/token", server.uri()), + "scopes_supported": [""], + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains(format!( + "refresh_token={REFRESH_TOKEN}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": REFRESHED_ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": REFRESH_TOKEN, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(header( + "authorization", + format!("Bearer {REFRESHED_ACCESS_TOKEN}"), + )) + .respond_with(|request: &Request| { + let body: Value = request.body_json().expect("valid JSON-RPC request"); + match body.get("method").and_then(Value::as_str) { + Some("initialize") => ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "result": { + "protocolVersion": body + .pointer("/params/protocolVersion") + .cloned() + .unwrap_or_else(|| json!("2025-06-18")), + "capabilities": {}, + "serverInfo": { + "name": "oauth-startup-test", + "version": "0.0.0-test", + }, + }, + })), + Some("notifications/initialized") => ResponseTemplate::new(202), + method => ResponseTemplate::new(400) + .set_body_string(format!("unexpected JSON-RPC method: {method:?}")), + } + }) + .expect(2) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let server_url = format!("{}/mcp", server.uri()); + + // Credential storage resolves CODEX_HOME from the process environment. + // Run the client half of the test in an ignored helper test so it can use + // an isolated home without mutating the parent test runner's environment. + let status = Command::new(std::env::current_exe()?) + .args(["oauth_startup_child", "--exact", "--ignored", "--nocapture"]) + .env("CODEX_HOME", codex_home.path()) + .env(CHILD_SERVER_URL_ENV, server_url) + .status() + .await?; + assert!(status.success(), "OAuth startup child failed: {status}"); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "spawned by refreshes_expired_persisted_token_before_initialize"] +async fn oauth_startup_child() -> anyhow::Result<()> { + let server_url = std::env::var(CHILD_SERVER_URL_ENV)?; + + // Save an expired access token with a valid refresh token so startup must + // refresh before sending the initialize request. + let mut response = OAuthTokenResponse::new( + AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + response.set_refresh_token(Some(RefreshToken::new(REFRESH_TOKEN.to_string()))); + response.set_expires_in(Some(&Duration::from_secs(7200))); + let tokens = StoredOAuthTokens { + server_name: SERVER_NAME.to_string(), + url: server_url.clone(), + client_id: "test-client-id".to_string(), + token_response: WrappedOAuthTokenResponse(response), + expires_at: Some(0), + }; + save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?; + + // This mirrors create_client's transport and initialization setup, except + // it omits the direct bearer token. Supplying that token would bypass the + // persisted OAuth credentials and the startup refresh under test. + let client = RmcpClient::new_streamable_http_client( + SERVER_NAME, + &server_url, + /*bearer_token*/ None, + /*http_headers*/ None, + /*env_http_headers*/ None, + OAuthCredentialsStoreMode::File, + Environment::default_for_tests().get_http_client(), + /*auth_provider*/ None, + ) + .await?; + + initialize_client(&client).await?; + Ok(()) +} diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index 822acef1a2..03ee79392c 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -86,6 +86,12 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result ) .await?; + initialize_client(&client).await?; + + Ok(client) +} + +pub(crate) async fn initialize_client(client: &RmcpClient) -> anyhow::Result<()> { client .initialize( init_params(), @@ -102,8 +108,7 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result }), ) .await?; - - Ok(client) + Ok(()) } /// Creates a Streamable HTTP RMCP client that sends traffic through the remote From 1d9c9c9f33735223cc564ec942001c9141a11eb1 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 20:23:37 -0700 Subject: [PATCH 053/141] [codex] Keep Bazel startup options stable across commands (#26256) ## Why `just bazel-clippy` ran target discovery with `--noexperimental_remote_repo_contents_cache`, then ran the build with the workspace default `--experimental_remote_repo_contents_cache`. Bazel therefore killed and restarted its server on each transition, slowing repeated commands and discarding the in-memory analysis cache. An audit found the same class of startup-option variation in several CI command sequences. ## What changed - Keep local lint target-discovery queries on the workspace-default Bazel server, while making CI target discovery explicitly use the CI startup options. - Normalize GitHub Actions launches through the BuildBuddy wrapper to share `BAZEL_OUTPUT_USER_ROOT` and `--noexperimental_remote_repo_contents_cache`. - Route the CI lockfile check and Windows test-shard query through the same startup configuration. - Document the startup-option invariant and add wrapper regression coverage. ## Validation - Confirmed consecutive local clippy target-discovery runs retained the same Bazel server PID. --- .github/scripts/run-bazel-query-ci.sh | 20 +++------ .github/scripts/run_bazel_with_buildbuddy.py | 43 ++++++++++++++++++- .../scripts/test_run_bazel_with_buildbuddy.py | 34 +++++++++++++++ .github/workflows/bazel.yml | 2 +- codex-rs/docs/bazel.md | 5 ++- scripts/check-module-bazel-lock.sh | 3 +- 6 files changed, 89 insertions(+), 18 deletions(-) diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index f5d4f56f49..39fa04c47d 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -4,7 +4,8 @@ set -euo pipefail # Run target-discovery queries with the same startup settings as the main # build/test invocation so they can reuse the same Bazel server. Queries only -# enumerate labels, so they intentionally do not select CI or remote configs. +# enumerate labels, so they intentionally do not select a CI build/test config +# or remote execution. if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then echo "Usage: $0 [...] -- " >&2 @@ -14,21 +15,16 @@ fi query_args=("${@:1:$#-2}") query_expression="${@: -1}" -bazel_startup_args=() -if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then - bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") -fi - run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" } -bazel_query_args=(--noexperimental_remote_repo_contents_cache query) +bazel_query_args=(query) if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -43,8 +39,4 @@ if (( ${#query_args[@]} > 0 )); then fi bazel_query_args+=("$query_expression") -if (( ${#bazel_startup_args[@]} > 0 )); then - run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" -else - run_bazel "${bazel_query_args[@]}" -fi +run_bazel "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py index f9f329d686..4503b4fda3 100755 --- a/.github/scripts/run_bazel_with_buildbuddy.py +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -22,6 +22,47 @@ REMOTE_EXECUTION_CONFIGS = { "--config=ci-v8", "--config=ci-windows-cross", } +# Honor either explicit setting so the wrapper never overrides the caller's +# choice when it supplies the CI default below. +REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS = { + "--experimental_remote_repo_contents_cache", + "--noexperimental_remote_repo_contents_cache", +} + + +def startup_args(args: Sequence[str], env: Mapping[str, str]) -> list[str]: + """Return shared startup options that are missing from a Bazel invocation. + + Bazel startup options must precede the command, and changing them restarts + the server and discards its analysis cache. GitHub Actions invokes Bazel + through several helpers, so normalize their startup options here while + preserving any explicit choice made by the caller. + """ + command_idx = next( + (idx for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + configured_startup_args = args[:command_idx] + injected_args = [] + + output_user_root = env.get("BAZEL_OUTPUT_USER_ROOT") + if output_user_root and not any( + arg.startswith("--output_user_root=") for arg in configured_startup_args + ): + injected_args.append(f"--output_user_root={output_user_root}") + + if env.get("GITHUB_ACTIONS") == "true" and not any( + arg in REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS + for arg in configured_startup_args + ): + # Work around Bazel 9 overlay materialization failures seen in CI. This + # disables only the startup-level repo contents cache; keyed runs still + # use BuildBuddy. + injected_args.append("--noexperimental_remote_repo_contents_cache") + + return injected_args + + # Only authenticated workflow runs executing trusted upstream code may use the # OpenAI BuildBuddy host. A pull request event without proof that its head is # in the upstream repository fails closed to the generic host. @@ -114,7 +155,7 @@ def bazel_args_with_remote_config( def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: env = os.environ if env is None else env bazel = env.get("CODEX_BAZEL_BIN", "bazel") - return [bazel, *bazel_args_with_remote_config(args, env)] + return [bazel, *startup_args(args, env), *bazel_args_with_remote_config(args, env)] def main() -> None: diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py index 0f594b7947..f06e34b895 100644 --- a/.github/scripts/test_run_bazel_with_buildbuddy.py +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -182,6 +182,38 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase): ["fake-bazel", "info", "execution_root"], ) + def test_bazel_command_normalizes_github_actions_startup_options(self) -> None: + env = { + "BAZEL_OUTPUT_USER_ROOT": "/tmp/bazel-output", + "GITHUB_ACTIONS": "true", + } + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command("build", "//codex-rs/...", env=env), + [ + "bazel", + "--output_user_root=/tmp/bazel-output", + "--noexperimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + ], + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "--experimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + env=env, + ), + [ + "bazel", + "--output_user_root=/tmp/bazel-output", + "--experimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + ], + ) + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: spaced_arg = ( r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" @@ -191,7 +223,9 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase): ) env = os.environ.copy() env["CODEX_BAZEL_BIN"] = sys.executable + env.pop("BAZEL_OUTPUT_USER_ROOT", None) env.pop("BUILDBUDDY_API_KEY", None) + env.pop("GITHUB_ACTIONS", None) result = subprocess.run( [ diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index bddbb5adc9..cc1ef09245 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -186,7 +186,7 @@ jobs: bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( - MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ + ./.github/scripts/run-bazel-query-ci.sh --output=label -- "${bazel_test_query}" \ | LC_ALL=C sort ) diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 085c15992f..f12eeefd70 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -81,7 +81,10 @@ GitHub Actions routes Bazel build and output-resolution commands through `.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` delegate remote configuration selection to that wrapper. The wrapper reads the GitHub Actions repository and event payload rather than relying on workflow -files to duplicate tenant-selection logic. +files to duplicate tenant-selection logic. It also normalizes GitHub Actions +startup options so all Bazel launches in a job reuse the same server and +in-memory analysis cache. Target-discovery and lockfile helpers delegate to the +same wrapper so their callers do not need to select CI-specific startup options. Loading-phase target-discovery `bazel query` commands run locally because they only enumerate labels and do not need remote caches or execution. diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh index 1a14889643..49ddaaef2b 100755 --- a/scripts/check-module-bazel-lock.sh +++ b/scripts/check-module-bazel-lock.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if ! bazel mod deps --lockfile_mode=error; then +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "${repo_root}/.github/scripts/run_bazel_with_buildbuddy.py" mod deps --lockfile_mode=error; then echo "MODULE.bazel.lock is out of date." echo "Run 'just bazel-lock-update' and commit the updated lockfile." exit 1 From a2f5874b7a2db79b46c60a73858ba4c4b4807c05 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 4 Jun 2026 21:48:45 -0700 Subject: [PATCH 054/141] core: derive exec policy filesystem policy from profile (#26499) ## Why `PermissionProfile` already owns the runtime filesystem sandbox policy through `file_system_sandbox_policy()`. Keeping a separate `FileSystemSandboxPolicy` on exec-policy fallback contexts made it possible for callers and tests to construct split states that the production permission model should not rely on. ## What changed - Removed `file_system_sandbox_policy` from `UnmatchedCommandContext`, `ExecApprovalRequest`, and the intercepted Unix exec-policy context. - Derived filesystem sandbox policy inside unmatched-command decision logic from `PermissionProfile::file_system_sandbox_policy()`. - Simplified shell/unified-exec callers and tests that were only plumbing the duplicate policy through. ## Testing Local tests not run per request; relying on remote CI. --- codex-rs/core/src/exec_policy.rs | 17 +--- codex-rs/core/src/exec_policy_tests.rs | 79 +------------------ .../core/src/exec_policy_windows_tests.rs | 4 - codex-rs/core/src/session/tests.rs | 2 - codex-rs/core/src/tools/handlers/shell.rs | 2 - .../tools/runtimes/shell/unix_escalation.rs | 4 - .../runtimes/shell/unix_escalation_tests.rs | 7 -- .../core/src/unified_exec/process_manager.rs | 2 - 8 files changed, 7 insertions(+), 110 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a44ffbe3ab..cc2f18c4fb 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -22,7 +22,6 @@ use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; -use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use codex_shell_command::is_safe_command::is_known_safe_command; @@ -121,7 +120,6 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, @@ -242,7 +240,6 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, @@ -277,7 +274,6 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, prefix_rule, @@ -298,7 +294,6 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, @@ -636,12 +631,12 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, command_origin, } = context; + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let is_known_safe = match command_origin { ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), #[cfg(windows)] @@ -652,12 +647,8 @@ pub(crate) fn render_decision_for_unmatched_command( // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. - let environment_lacks_sandbox_protections = cfg!(windows) - && profile_is_managed_read_only( - permission_profile, - file_system_sandbox_policy, - sandbox_cwd, - ); + let environment_lacks_sandbox_protections = + cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd); if is_known_safe && !used_complex_parsing @@ -751,9 +742,9 @@ pub(crate) fn render_decision_for_unmatched_command( fn profile_is_managed_read_only( permission_profile: &PermissionProfile, - file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_cwd: &Path, ) -> bool { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( file_system_sandbox_policy.kind, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 7b0883db2a..1adf6d471b 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -19,6 +19,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -104,31 +105,6 @@ async fn write_project_trust_config( .await } -fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]) -} - -fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::workspace_write( - &[], - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ) -} - -fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::unrestricted() -} - -fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::external_sandbox() -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -665,7 +641,6 @@ async fn evaluates_bash_lc_inner_commands() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -761,7 +736,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() { command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -785,7 +759,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -809,7 +782,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ "python3".to_string(), @@ -837,7 +809,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), }, @@ -862,7 +833,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -893,7 +863,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -927,7 +896,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -967,7 +935,6 @@ prefix_rule( ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -986,7 +953,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1014,7 +980,6 @@ prefix_rule(pattern=["git"], decision="allow") command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1048,7 +1013,6 @@ prefix_rule(pattern=["git"], decision="prompt") command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1075,7 +1039,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }, @@ -1098,7 +1061,6 @@ async fn exec_approval_requirement_respects_approval_policy() { command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1126,7 +1088,6 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1137,9 +1098,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { +fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() { let command = vec!["madeup-cmd".to_string()]; - let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); assert_eq!( Decision::Prompt, @@ -1147,8 +1107,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &PermissionProfile::Disabled, - file_system_sandbox_policy: &restricted_file_system_policy, + permission_profile: &PermissionProfile::read_only(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1169,7 +1128,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1202,7 +1160,6 @@ fn managed_cwd_write_profile_is_not_read_only() { assert!(!profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1233,7 +1190,6 @@ fn managed_unresolvable_write_profile_is_still_read_only() { assert!(profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1250,7 +1206,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, }, @@ -1273,7 +1228,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1303,7 +1257,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s mcp_elicitations: true, }), permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1329,7 +1282,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1365,7 +1317,6 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1403,7 +1354,6 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1428,7 +1378,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1454,7 +1403,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1484,7 +1432,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1514,7 +1461,6 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1547,7 +1493,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1587,7 +1532,6 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1663,7 +1607,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1683,7 +1626,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1707,7 +1649,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1737,7 +1678,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1761,7 +1701,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1785,7 +1724,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1816,7 +1754,6 @@ prefix_rule(pattern=["cat"], decision="allow") command: command.clone(), approval_policy, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1848,7 +1785,6 @@ prefix_rule(pattern=["bash"], decision="allow") ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2014,7 +1950,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2078,7 +2013,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2103,7 +2037,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2124,7 +2057,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2146,7 +2078,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2171,7 +2102,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2188,7 +2118,6 @@ struct ExecApprovalRequirementScenario { command: Vec, approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, } @@ -2212,7 +2141,6 @@ async fn exec_approval_requirement_for_command( command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_permissions, prefix_rule, } = test; @@ -2224,7 +2152,6 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: Path::new("/tmp"), sandbox_permissions, prefix_rule, diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 1d14d93813..3fba240b41 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -15,7 +15,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { ], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -39,7 +38,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -81,7 +79,6 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, @@ -110,7 +107,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c5e37fca0e..a2023ea378 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10868,7 +10868,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); let command = session.user_shell().derive_exec_args( command_script, turn_context.config.permissions.allow_login_shell, @@ -10880,7 +10879,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 504653cf76..b3c955595a 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -160,7 +160,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result { approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: &'a FileSystemSandboxPolicy, sandbox_cwd: &'a Path, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6e7a1ced54..ecdf015fd5 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -522,7 +522,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -575,7 +574,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -619,7 +617,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, @@ -747,7 +744,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( @@ -757,7 +753,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, @@ -773,7 +768,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, @@ -808,7 +802,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 66b8b49a80..2c86deafaf 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1011,7 +1011,6 @@ impl UnifiedExecProcessManager { }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); - let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session .services @@ -1020,7 +1019,6 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, // The process cwd may be model-controlled. Policy resolution // stays anchored to the selected turn environment cwd instead. sandbox_cwd: request.sandbox_cwd.as_path(), From 64e0829cab9dccb14b8273a2ebea2eee9e06c12c Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Thu, 4 Jun 2026 22:12:23 -0700 Subject: [PATCH 055/141] feat(remote-control): allow pairing while disabled (#26215) ## Why `remoteControl/pairing/start` creates authorization for future remote-control connections, so it should not require the live websocket to already be enabled. Requiring enable first made pairing depend on presence instead of the persisted server enrollment that pairing actually uses. Pairing also needs to recover when that persisted server row is stale. If `/server/pair` returns `404`, making the first pairing attempt fail forces a manual retry even though the client can clear the stale row and create a replacement enrollment immediately. ## What Changed - Allow `remoteControl/pairing/start` to reuse or create the persisted remote-control server enrollment while remote control is disabled. - Keep the selected in-memory enrollment across disable and share it with websocket connect so a later enable uses the same selected server. - Thread the app-server client name through pairing so stdio persistence keeps using the websocket-owned enrollment key. - Recover pairing server-token auth failures through the existing refresh/auth-recovery path. - Recover stale pairing enrollment on `/server/pair` `404` by clearing the stale selected enrollment, re-enrolling once, and retrying pairing once. - Add focused disabled-pairing and stale-pairing recovery coverage. ## Verification - `remote_control_pairing_start_returns_pairing_artifacts_while_disabled` exercises pairing before enable. - `remote_control_handle_reenrolls_after_stale_pairing_enrollment` exercises stale `/server/pair` `404` recovery without a manual retry. Related: N/A --- .../src/transport/remote_control/mod.rs | 353 +++++++++++++++--- .../src/transport/remote_control/tests.rs | 30 +- .../remote_control/tests/clients_tests.rs | 5 +- .../remote_control/tests/pairing_tests.rs | 156 +++++++- .../src/transport/remote_control/websocket.rs | 348 +++++++++-------- codex-rs/app-server/src/message_processor.rs | 2 +- .../remote_control_processor.rs | 3 +- .../remote_control_processor_tests.rs | 5 +- .../tests/suite/v2/remote_control.rs | 44 ++- 9 files changed, 702 insertions(+), 244 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 38e34e6d43..443dcc897e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -9,7 +9,10 @@ mod websocket; use self::auth::load_remote_control_auth; use self::auth::recover_remote_control_auth; use self::enroll::RemoteControlEnrollment; +use self::enroll::enroll_remote_control_server; +use self::enroll::load_persisted_remote_control_enrollment; use self::enroll::refresh_remote_control_server; +use self::enroll::update_persisted_remote_control_enrollment; use crate::transport::remote_control::websocket::RemoteControlChannels; use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; @@ -36,9 +39,13 @@ use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; +use std::ops::Deref; +use std::ops::DerefMut; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -65,12 +72,81 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + state_db: Option>, remote_control_url: String, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, + pairing_persistence_key_required: bool, auth_manager: Arc, } -type CurrentRemoteControlEnrollment = Arc>>; +// Pairing and websocket connect share one selected server so they cannot enroll or clear +// different persisted rows while either path is awaiting backend I/O. +type CurrentRemoteControlEnrollment = Arc; +type RemoteControlPairingPersistenceKey = watch::Sender>; + +struct RemoteControlEnrollmentState { + enrollment: StdMutex>, + lock: Semaphore, +} + +impl RemoteControlEnrollmentState { + fn new(enrollment: Option) -> Self { + Self { + enrollment: StdMutex::new(enrollment), + lock: Semaphore::new(1), + } + } + + async fn lock(&self) -> RemoteControlEnrollmentLease<'_> { + let permit = match self.lock.acquire().await { + Ok(permit) => permit, + Err(_) => unreachable!("remote control enrollment lock should stay open"), + }; + RemoteControlEnrollmentLease { + state: self, + enrollment: self.snapshot(), + _permit: permit, + } + } + + fn snapshot(&self) -> Option { + self.enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +struct RemoteControlEnrollmentLease<'a> { + state: &'a RemoteControlEnrollmentState, + enrollment: Option, + _permit: SemaphorePermit<'a>, +} + +impl Deref for RemoteControlEnrollmentLease<'_> { + type Target = Option; + + fn deref(&self) -> &Self::Target { + &self.enrollment + } +} + +impl DerefMut for RemoteControlEnrollmentLease<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.enrollment + } +} + +impl Drop for RemoteControlEnrollmentLease<'_> { + fn drop(&mut self) { + *self + .state + .enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.enrollment.take(); + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteControlUnavailable; @@ -126,8 +202,6 @@ impl RemoteControlHandle { *state = false; changed }); - clear_current_enrollment(&self.current_enrollment); - let status = self.status(); info!( enabled_changed, @@ -151,28 +225,30 @@ impl RemoteControlHandle { pub async fn start_pairing( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> io::Result { - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let mut auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; - let mut enrollment = { - let current_enrollment = self - .current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - current_enrollment - .as_ref() - .filter(|enrollment| enrollment.account_id == auth.account_id) - .cloned() - } - .ok_or_else(pairing_unavailable_error)?; - let installation_id = self.status().installation_id; + let status = self.status(); + let installation_id = status.installation_id; + let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?; + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; if enrollment.should_refresh_server_token() { refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -185,9 +261,11 @@ impl RemoteControlHandle { }; let pairing_response = match enrollment.start_pairing(pairing_request()).await { Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -196,24 +274,46 @@ impl RemoteControlHandle { .await?; enrollment.start_pairing(pairing_request()).await } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; + enrollment.start_pairing(pairing_request()).await + } pairing_response => pairing_response, }; if let Err(err) = &pairing_response { match err.kind() { io::ErrorKind::NotFound => { - clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment); + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; return Err(pairing_unavailable_error()); } io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; return Err(pairing_unavailable_error()); } _ => {} } } - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let current_auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; @@ -223,6 +323,74 @@ impl RemoteControlHandle { pairing_response } + async fn load_or_enroll_pairing_server( + &self, + current_enrollment: &mut Option, + auth: &mut auth::RemoteControlConnectionAuth, + installation_id: &str, + server_name: &str, + app_server_client_name: Option<&str>, + ) -> io::Result { + if let Some(enrollment) = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + { + return Ok(enrollment); + } + + let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?; + let state_db = self + .state_db + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + if let Some(mut enrollment) = load_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + ) + .await? + { + enrollment.server_name = server_name.to_string(); + publish_current_enrollment(current_enrollment, &enrollment); + return Ok(enrollment); + } + + let enrollment = enroll_pairing_server( + &self.auth_manager, + auth, + &remote_control_target, + installation_id, + server_name, + ) + .await?; + update_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + Some(&enrollment), + ) + .await?; + publish_current_enrollment(current_enrollment, &enrollment); + Ok(enrollment) + } + + fn pairing_persistence_key( + &self, + app_server_client_name: Option<&str>, + ) -> io::Result> { + if self.pairing_persistence_key_required && self.pairing_persistence_key.borrow().is_none() + { + let app_server_client_name = + app_server_client_name.ok_or_else(pairing_unavailable_error)?; + self.pairing_persistence_key + .send_replace(Some(app_server_client_name.to_string())); + } + Ok(self.pairing_persistence_key.borrow().clone()) + } + pub async fn list_clients( &self, params: RemoteControlClientsListParams, @@ -239,13 +407,6 @@ impl RemoteControlHandle { .await } - fn pairing_disabled_error() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidInput, - "remote control pairing requires remote control to be enabled", - ) - } - fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -277,8 +438,36 @@ impl RemoteControlHandle { } } +async fn enroll_pairing_server( + auth_manager: &Arc, + auth: &mut auth::RemoteControlConnectionAuth, + remote_control_target: &protocol::RemoteControlTarget, + installation_id: &str, + server_name: &str, +) -> io::Result { + match enroll_remote_control_server(remote_control_target, auth, installation_id, server_name) + .await + { + Ok(enrollment) => return Ok(enrollment), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + return Err(err); + } + *auth = load_remote_control_auth(auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + } + Err(err) => return Err(err), + } + enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await +} + async fn refresh_pairing_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, auth_manager: &Arc, auth: &mut auth::RemoteControlConnectionAuth, installation_id: &str, @@ -286,7 +475,14 @@ async fn refresh_pairing_enrollment( ) -> io::Result<()> { if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { if err.kind() != io::ErrorKind::PermissionDenied { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); @@ -300,7 +496,14 @@ async fn refresh_pairing_enrollment( return Err(pairing_unavailable_error()); } if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } } if replace_current_enrollment(current_enrollment, enrollment) { @@ -310,21 +513,54 @@ async fn refresh_pairing_enrollment( } } -fn handle_pairing_refresh_error( - current_enrollment: &CurrentRemoteControlEnrollment, +async fn handle_pairing_refresh_error( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, enrollment: &RemoteControlEnrollment, err: io::Error, ) -> io::Result<()> { if err.kind() == io::ErrorKind::NotFound { - clear_current_enrollment_if_matches(current_enrollment, enrollment); + clear_pairing_enrollment( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + ) + .await; Err(pairing_unavailable_error()) } else { Err(err) } } +async fn clear_pairing_enrollment( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, + enrollment: &RemoteControlEnrollment, +) { + if !clear_current_enrollment_if_matches(current_enrollment, enrollment) { + return; + } + let Some(state_db) = state_db else { + return; + }; + if let Err(err) = update_persisted_remote_control_enrollment( + Some(state_db), + &enrollment.remote_control_target, + &enrollment.account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!("failed to clear stale pairing enrollment: {err}"); + } +} + fn clear_pairing_server_token( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { enrollment.clear_server_token(); @@ -359,27 +595,16 @@ fn remote_control_status_with_connection_status( } fn publish_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone()); -} - -fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *current_enrollment = Some(enrollment.clone()); } fn replace_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) -> bool { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); if !current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) @@ -391,17 +616,17 @@ fn replace_current_enrollment( } fn clear_current_enrollment_if_matches( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, -) { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +) -> bool { if current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) { *current_enrollment = None; + true + } else { + false } } @@ -438,9 +663,13 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); - let current_enrollment = Arc::new(StdMutex::new(None)); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)); let websocket_current_enrollment = current_enrollment.clone(); + let pairing_persistence_key_required = app_server_client_name_rx.is_some(); + let (pairing_persistence_key, _pairing_persistence_key_rx) = watch::channel(None); + let websocket_pairing_persistence_key = pairing_persistence_key.clone(); let handle_auth_manager = auth_manager.clone(); + let handle_state_db = state_db.clone(); let server_name = gethostname().to_string_lossy().trim().to_string(); let remote_control_url = config.remote_control_url; let installation_id = config.installation_id; @@ -490,6 +719,7 @@ pub async fn start_remote_control( transport_event_tx, status_publisher, current_enrollment: websocket_current_enrollment, + pairing_persistence_key: websocket_pairing_persistence_key, }, shutdown_token, enabled_rx, @@ -534,8 +764,11 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + state_db: handle_state_db, remote_control_url: handle_remote_control_url, current_enrollment, + pairing_persistence_key, + pairing_persistence_key_required, auth_manager: handle_auth_manager, }, )) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index a1a284da9b..3e9a911b2d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -40,7 +40,6 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use tempfile::TempDir; use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; @@ -148,24 +147,29 @@ fn remote_control_handle_with_current_enrollment( }); let remote_control_target = normalize_remote_control_url(remote_control_url) .expect("remote control target should normalize"); - let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment { - remote_control_target, - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: test_server_name(), - remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), - expires_at: Some( - OffsetDateTime::from_unix_timestamp(33_336_362_096) - .expect("future timestamp should parse"), - ), - }))); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(Some( + RemoteControlEnrollment { + remote_control_target, + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: test_server_name(), + remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + }, + ))); RemoteControlHandle { enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: true, + state_db: None, remote_control_url: remote_control_url.to_string(), current_enrollment, + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 22aa290762..060063909c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -24,8 +24,11 @@ fn client_management_handle( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: false, + state_db: None, remote_control_url, - current_enrollment: Arc::new(StdMutex::new(None)), + current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)), + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 176aeb4ed5..7586f0b4d1 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -131,13 +131,16 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams { manual_code: true }) + .start_pairing( + RemoteControlPairingStartParams { manual_code: true }, + /*app_server_client_name*/ None, + ) .await .expect("pairing should use the current server before websocket connect"); server_task.await.expect("server task should finish"); @@ -219,7 +222,10 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() { ); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after server token auth failure"); server_task.await.expect("server task should finish"); @@ -332,13 +338,16 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after auth recovery"); server_task.await.expect("server task should finish"); @@ -414,21 +423,137 @@ async fn start_remote_control_pairing_preserves_expiry_parse_error_context() { } #[tokio::test] -async fn remote_control_handle_disable_clears_current_enrollment() { +async fn remote_control_handle_disable_keeps_current_enrollment() { let remote_handle = remote_control_handle_with_current_enrollment( TEST_REMOTE_CONTROL_URL, remote_control_auth_manager(), ); remote_handle.disable(); - remote_handle.enable().expect("enable should succeed"); + assert!( + remote_handle.current_enrollment.lock().await.is_some(), + "disabled remote control should keep the selected pairing server" + ); +} + +#[tokio::test] +async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let mut remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager_with_home(&codex_home), + ); + remote_handle.state_db = Some(state_db.clone()); + remote_handle.disable(); + let stale_enrollment = remote_handle + .current_enrollment + .lock() + .await + .clone() + .expect("current enrollment should exist"); + let remote_control_target = stale_enrollment.remote_control_target.clone(); + let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: test_server_name(), + remote_control_token: None, + expires_at: None, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + let server_refreshed_enrollment = refreshed_enrollment.clone(); + let server_task = tokio::spawn(async move { + let stale_pairing_request = accept_http_request(&listener).await; + assert_eq!( + stale_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + stale_pairing_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_pairing_request.stream, "404 Not Found", "").await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + remote_control_server_token_response( + &server_refreshed_enrollment.server_id, + &server_refreshed_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_pairing_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + refreshed_pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + refreshed_pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": server_refreshed_enrollment.server_id, + "environment_id": server_refreshed_enrollment.environment_id, + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let response = remote_handle + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) + .await + .expect("pairing should re-enroll after stale enrollment"); + server_task.await.expect("server task should finish"); + assert_eq!( - remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) - .await - .expect_err("re-enabled remote control should wait for a current server") - .to_string(), - "remote control pairing is unavailable until enrollment completes" + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_refreshed".to_string(), + expires_at: 33_336_362_096, + } + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("refreshed enrollment should load"), + Some(refreshed_enrollment) ); } @@ -458,7 +583,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { let remote_handle = remote_handle.clone(); async move { remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await } }); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 60792df54c..43fce8ed36 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1,13 +1,13 @@ use super::CurrentRemoteControlEnrollment; -use super::clear_current_enrollment; +use super::RemoteControlPairingPersistenceKey; use super::protocol::ClientEnvelope; use super::protocol::ClientEvent; use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; -use super::publish_current_enrollment; use super::remote_control_status_with_connection_status; +use super::same_remote_control_enrollment; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -55,6 +55,9 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_util::sync::CancellationToken; + +#[cfg(test)] +use super::RemoteControlEnrollmentState; use tracing::error; use tracing::info; use tracing::warn; @@ -252,10 +255,10 @@ pub(crate) struct RemoteControlWebsocket { status_publisher: RemoteControlStatusPublisher, shutdown_token: CancellationToken, reconnect_attempt: u64, - enrollment: Option, auth_recovery: UnauthorizedRecovery, auth_change_rx: watch::Receiver, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -294,6 +297,7 @@ pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, pub(super) current_enrollment: CurrentRemoteControlEnrollment, + pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey, } #[derive(Clone)] @@ -407,10 +411,10 @@ impl RemoteControlWebsocket { status_publisher: channels.status_publisher, shutdown_token, reconnect_attempt: 0, - enrollment: None, auth_recovery, auth_change_rx, current_enrollment: channels.current_enrollment, + pairing_persistence_key: channels.pairing_persistence_key, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -457,6 +461,8 @@ impl RemoteControlWebsocket { return; } }; + self.pairing_persistence_key + .send_replace(app_server_client_name.clone()); loop { if !self.wait_until_enabled().await { @@ -579,15 +585,15 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, reconnect_attempt = self.reconnect_attempt.saturating_add(1), has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), app_server_client_name = ?app_server_client_name, "connecting to app-server remote control websocket" @@ -611,15 +617,17 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } - connect_result = connect_remote_control_websocket( - &remote_control_target, - self.state_db.as_deref(), - auth_context, - &mut self.enrollment, - connect_options, - &self.status_publisher, - &self.current_enrollment, - ) => connect_result, + connect_result = async { + connect_remote_control_websocket( + &remote_control_target, + self.state_db.as_deref(), + auth_context, + &self.current_enrollment, + connect_options, + &self.status_publisher, + ) + .await + } => connect_result, }; match connect_result { @@ -631,13 +639,13 @@ impl RemoteControlWebsocket { self.auth_recovery = self.auth_manager.unauthorized_recovery(); self.status_publisher .publish_status(RemoteControlConnectionStatus::Connected); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), response_headers = %format_headers(response.headers()), "connected to app-server remote control websocket" @@ -656,7 +664,7 @@ impl RemoteControlWebsocket { let reconnect_attempt = self.reconnect_attempt.saturating_add(1); let (reconnect_delay, reconnect_backoff_reset) = next_reconnect_delay(&mut self.reconnect_attempt); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); warn!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, @@ -667,8 +675,8 @@ impl RemoteControlWebsocket { reconnect_delay = ?reconnect_delay, reconnect_backoff_reset, has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), "failed to connect to app-server remote control websocket" ); @@ -1189,19 +1197,108 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, mut auth_context: RemoteControlAuthContext<'_>, - enrollment: &mut Option, + current_enrollment: &CurrentRemoteControlEnrollment, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<( WebSocketStream>, tungstenite::http::Response<()>, )> { ensure_rustls_crypto_provider(); + let (auth, enrollment) = { + let mut current_enrollment = current_enrollment.lock().await; + let auth = prepare_remote_control_enrollment( + remote_control_target, + state_db, + &mut auth_context, + &mut current_enrollment, + connect_options, + status_publisher, + ) + .await?; + let enrollment = current_enrollment.as_ref().cloned().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + (auth, enrollment) + }; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + &enrollment, + connect_options.installation_id, + connect_options.subscribe_cursor, + )?; + + let websocket_connect_result = tokio::time::timeout( + REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, + connect_async(request), + ) + .await + .map_err(|_| { + io::Error::new( + ErrorKind::TimedOut, + format!( + "timed out connecting to remote control websocket at `{}` after {:?}", + remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT + ), + ) + })?; + + match websocket_connect_result { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment.server_id, + enrollment.environment_id + ); + clear_remote_control_enrollment_if_matches( + state_db, + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + current_enrollment, + &enrollment, + status_publisher, + ) + .await; + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + clear_remote_control_server_token_if_matches(current_enrollment, &enrollment) + .await?; + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", + response.status() + ))); + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn prepare_remote_control_enrollment( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_context: &mut RemoteControlAuthContext<'_>, + enrollment: &mut Option, + connect_options: RemoteControlConnectOptions<'_>, + status_publisher: &RemoteControlStatusPublisher, +) -> io::Result { let Some(state_db) = state_db else { *enrollment = None; - clear_current_enrollment(current_enrollment); return Err(io::Error::new( ErrorKind::NotFound, "remote control requires sqlite state db", @@ -1214,7 +1311,6 @@ pub(super) async fn connect_remote_control_websocket( if err.kind() == ErrorKind::PermissionDenied { *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } return Err(err); } @@ -1231,7 +1327,6 @@ pub(super) async fn connect_remote_control_websocket( ); *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } if let Some(enrollment) = enrollment.as_mut() { enrollment.remote_control_target = remote_control_target.clone(); @@ -1262,7 +1357,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1307,14 +1402,13 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, - current_enrollment, ) .await; enroll_remote_control_server_if_missing( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1337,82 +1431,7 @@ pub(super) async fn connect_remote_control_websocket( } } - let enrollment_ref = enrollment.as_ref().ok_or_else(|| { - io::Error::other("missing remote control enrollment after enrollment step") - })?; - publish_current_enrollment(current_enrollment, enrollment_ref); - let request = build_remote_control_websocket_request( - &remote_control_target.websocket_url, - enrollment_ref, - connect_options.installation_id, - connect_options.subscribe_cursor, - )?; - - let websocket_connect_result = tokio::time::timeout( - REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, - connect_async(request), - ) - .await - .map_err(|_| { - io::Error::new( - ErrorKind::TimedOut, - format!( - "timed out connecting to remote control websocket at `{}` after {:?}", - remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT - ), - ) - })?; - - match websocket_connect_result { - Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), - Err(err) => { - match &err { - tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { - info!( - "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", - remote_control_target.websocket_url, - auth.account_id, - enrollment_ref.server_id, - enrollment_ref.environment_id - ); - clear_remote_control_enrollment( - state_db, - remote_control_target, - &auth.account_id, - connect_options.app_server_client_name, - enrollment, - status_publisher, - current_enrollment, - ) - .await; - } - tungstenite::Error::Http(response) - if matches!(response.status().as_u16(), 401 | 403) => - { - enrollment - .as_mut() - .ok_or_else(|| { - io::Error::other( - "missing remote control enrollment after websocket auth failure", - ) - })? - .clear_server_token(); - clear_current_enrollment(current_enrollment); - return Err(io::Error::other(format!( - "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", - response.status() - ))); - } - _ => {} - } - Err(io::Error::other( - format_remote_control_websocket_connect_error( - &remote_control_target.websocket_url, - &err, - ), - )) - } - } + Ok(auth) } async fn clear_remote_control_enrollment( @@ -1422,7 +1441,6 @@ async fn clear_remote_control_enrollment( app_server_client_name: Option<&str>, enrollment: &mut Option, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) { if let Err(clear_err) = update_persisted_remote_control_enrollment( Some(state_db), @@ -1437,7 +1455,51 @@ async fn clear_remote_control_enrollment( } *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); +} + +async fn clear_remote_control_enrollment_if_matches( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, + status_publisher: &RemoteControlStatusPublisher, +) { + let Some(state_db) = state_db else { + return; + }; + let mut current_enrollment = current_enrollment.lock().await; + if !current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + return; + } + clear_remote_control_enrollment( + state_db, + remote_control_target, + account_id, + app_server_client_name, + &mut current_enrollment, + status_publisher, + ) + .await; +} + +async fn clear_remote_control_server_token_if_matches( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) -> io::Result<()> { + let mut current_enrollment = current_enrollment.lock().await; + current_enrollment + .as_mut() + .filter(|current| same_remote_control_enrollment(current, enrollment)) + .ok_or_else(|| { + io::Error::other("missing remote control enrollment after websocket auth failure") + })? + .clear_server_token(); + Ok(()) } async fn enroll_remote_control_server_if_missing( @@ -1585,8 +1647,10 @@ mod tests { } } - fn test_current_enrollment() -> CurrentRemoteControlEnrollment { - Arc::new(std::sync::Mutex::new(None)) + fn test_current_enrollment( + enrollment: Option, + ) -> CurrentRemoteControlEnrollment { + Arc::new(RemoteControlEnrollmentState::new(enrollment)) } #[test] @@ -1739,10 +1803,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( @@ -1753,7 +1816,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1761,7 +1824,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await { @@ -1771,12 +1833,7 @@ mod tests { server_task.await.expect("server task should succeed"); assert_eq!(err.to_string(), expected_error); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_some() - ); + assert!(current_enrollment.lock().await.is_some()); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -1801,10 +1858,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let server_task = tokio::spawn(async move { @@ -1824,7 +1880,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1832,7 +1888,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await .expect_err("unauthorized response should fail the websocket connect"); @@ -1853,13 +1908,7 @@ mod tests { ); let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None); expected_enrollment.remote_control_target = remote_control_target; - assert_eq!(enrollment, Some(expected_enrollment)); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() - ); + assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment)); } #[tokio::test] @@ -1896,7 +1945,7 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = None; + let current_enrollment = test_current_enrollment(/*enrollment*/ None); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -1913,7 +1962,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1921,7 +1970,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized enrollment should fail the websocket connect"); @@ -1989,9 +2037,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment( /*remote_control_token*/ None, - )); + ))); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -2008,7 +2056,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2016,7 +2064,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized refresh should fail the websocket connect"); @@ -2061,9 +2108,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, _status_rx) = remote_control_status_channel(); let err = connect_remote_control_websocket( @@ -2074,7 +2121,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2082,14 +2129,13 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing sqlite state db should fail remote control"); assert_eq!(err.kind(), ErrorKind::NotFound); assert_eq!(err.to_string(), "remote control requires sqlite state db"); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); } #[tokio::test] @@ -2107,9 +2153,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, mut status_rx) = remote_control_status_channel(); status_publisher.publish_environment_id(Some("env_test".to_string())); status_rx @@ -2125,7 +2171,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2133,7 +2179,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing auth should fail remote control"); @@ -2143,7 +2188,7 @@ mod tests { err.to_string(), "remote control requires ChatGPT authentication" ); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -2185,7 +2230,8 @@ mod tests { RemoteControlChannels { transport_event_tx, status_publisher, - current_enrollment: test_current_enrollment(), + current_enrollment: test_current_enrollment(/*enrollment*/ None), + pairing_persistence_key: watch::channel(None).0, }, shutdown_token, enabled_rx, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d89f0b0645..6c578ce8e5 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -918,7 +918,7 @@ impl MessageProcessor { .map(|response| Some(response.into())), ClientRequest::RemoteControlPairingStart { params, .. } => self .remote_control_processor - .pairing_start(params) + .pairing_start(params, app_server_client_name.as_deref()) .await .map(|response| Some(response.into())), ClientRequest::RemoteControlClientsList { params, .. } => self diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 804339c17a..22223687bc 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -52,9 +52,10 @@ impl RemoteControlRequestProcessor { pub(crate) async fn pairing_start( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> Result { self.handle()? - .start_pairing(params) + .start_pairing(params, app_server_client_name) .await .map_err(map_pairing_start_error) } diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index 4381e48959..e336491cd3 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -6,7 +6,10 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() { let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) - .pairing_start(RemoteControlPairingStartParams::default()) + .pairing_start( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect_err("missing remote control should fail pairing"); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index b5384d3bad..c48ffda0f6 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -193,6 +193,42 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> Ok(()) } +#[tokio::test] +async fn remote_control_pairing_start_returns_pairing_artifacts_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStartResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "environment-id".to_string(), + expires_at: 33_336_362_096, + } + ); + Ok(()) +} + #[tokio::test] async fn remote_control_client_management_works_while_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -376,8 +412,12 @@ impl PairingRemoteControlBackend { ) .await?; - let _websocket_request = read_http_request(&listener).await?; - let pair_http_request = read_http_request(&listener).await?; + let request_after_enroll = read_http_request(&listener).await?; + let pair_http_request = if request_after_enroll.request_line.starts_with("GET ") { + read_http_request(&listener).await? + } else { + request_after_enroll + }; respond_with_json( pair_http_request.reader.into_inner(), serde_json::json!({ From 6a6a5f925e97bee23d5d31938b5f9f9f38becb8a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 22:36:25 -0700 Subject: [PATCH 056/141] [codex] Add environment shell info (#26480) ## Why Shell detection needs to be available through the `Environment` abstraction so callers can ask the selected local or remote environment for shell metadata without adding a separate HTTP endpoint or parallel info-source path. This keeps shell metadata shaped like the existing environment-owned filesystem capability and lets remote environments answer through exec-server JSON-RPC. ## What changed - Added `environment/info` to the exec-server protocol/client/server and exposed `Environment::info()`. - Added local and remote environment info providers on `Environment`, following the existing capability-provider pattern used for filesystem access. - Moved the shared shell detection logic into `codex-shell-command` and kept core shell APIs as wrappers around that implementation. - Returned shell metadata as `EnvironmentInfo { shell: ShellInfo }` using the existing shell detection path. - Added a remote environment test that calls `Environment::info()` through an exec-server-backed environment. ## Validation - `git diff --check` - `just test -p codex-shell-command` - `just test -p codex-core -E 'test(/shell::tests::/)'`\n- `just test -p codex-exec-server environment` --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/shell.rs | 328 +---------------- codex-rs/core/src/shell_detect.rs | 24 -- codex-rs/core/src/shell_snapshot.rs | 8 +- .../src/tools/handlers/shell/shell_command.rs | 2 +- .../core/src/tools/handlers/unified_exec.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 2 +- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/client.rs | 12 + codex-rs/exec-server/src/environment.rs | 62 ++++ codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/protocol.rs | 18 + codex-rs/exec-server/src/server/handler.rs | 6 + codex-rs/exec-server/src/server/registry.rs | 5 + codex-rs/exec-server/tests/health.rs | 15 + codex-rs/shell-command/Cargo.toml | 1 + codex-rs/shell-command/src/bash.rs | 2 +- codex-rs/shell-command/src/lib.rs | 2 +- codex-rs/shell-command/src/powershell.rs | 2 +- codex-rs/shell-command/src/shell_detect.rs | 346 +++++++++++++++++- 21 files changed, 488 insertions(+), 355 deletions(-) delete mode 100644 codex-rs/core/src/shell_detect.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6da7688435..b899222384 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2789,6 +2789,7 @@ dependencies = [ "codex-file-system", "codex-protocol", "codex-sandboxing", + "codex-shell-command", "codex-test-binary-support", "codex-utils-absolute-path", "codex-utils-pty", @@ -3692,6 +3693,7 @@ dependencies = [ "base64 0.22.1", "codex-protocol", "codex-utils-absolute-path", + "libc", "once_cell", "pretty_assertions", "regex", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b77f96cdb1..4dc19de7e6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -83,7 +83,6 @@ mod sandbox_tags; pub mod sandboxing; mod session_prefix; mod session_startup_prewarm; -mod shell_detect; pub mod skills; pub(crate) use skills::SkillInjections; pub(crate) use skills::SkillLoadOutcome; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 48c760d667..9843d4cea5 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,19 +1,12 @@ -use crate::shell_detect::detect_shell_type; use crate::shell_snapshot::ShellSnapshot; +use codex_shell_command::shell_detect::DetectedShell; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::watch; -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub enum ShellType { - Zsh, - Bash, - PowerShell, - Sh, - Cmd, -} +pub use codex_shell_command::shell_detect::ShellType; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Shell { @@ -29,13 +22,7 @@ pub struct Shell { impl Shell { pub fn name(&self) -> &'static str { - match self.shell_type { - ShellType::Zsh => "zsh", - ShellType::Bash => "bash", - ShellType::PowerShell => "powershell", - ShellType::Sh => "sh", - ShellType::Cmd => "cmd", - } + self.shell_type.name() } /// Takes a string of shell and returns the full list of command args to @@ -88,319 +75,36 @@ impl PartialEq for Shell { impl Eq for Shell {} -#[cfg(unix)] -fn get_user_shell_path() -> Option { - let uid = unsafe { libc::getuid() }; - use std::ffi::CStr; - use std::mem::MaybeUninit; - use std::ptr; - - let mut passwd = MaybeUninit::::uninit(); - - // We cannot use getpwuid here: it returns pointers into libc-managed - // storage, which is not safe to read concurrently on all targets (the musl - // static build used by the CLI can segfault when parallel callers race on - // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. - let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; - let buffer_len = usize::try_from(suggested_buffer_len) - .ok() - .filter(|len| *len > 0) - .unwrap_or(1024); - let mut buffer = vec![0; buffer_len]; - - loop { - let mut result = ptr::null_mut(); - let status = unsafe { - libc::getpwuid_r( - uid, - passwd.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) - }; - - if status == 0 { - if result.is_null() { - return None; - } - - let passwd = unsafe { passwd.assume_init_ref() }; - if passwd.pw_shell.is_null() { - return None; - } - - let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } - .to_string_lossy() - .into_owned(); - return Some(PathBuf::from(shell_path)); - } - - if status != libc::ERANGE { - return None; - } - - // Retry with a larger buffer until libc can materialize the passwd entry. - let new_len = buffer.len().checked_mul(2)?; - if new_len > 1024 * 1024 { - return None; - } - buffer.resize(new_len, 0); - } -} - -#[cfg(not(unix))] -fn get_user_shell_path() -> Option { - None -} - -fn file_exists(path: &PathBuf) -> Option { - if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { - Some(PathBuf::from(path)) - } else { - None - } -} - -fn get_shell_path( - shell_type: ShellType, - provided_path: Option<&PathBuf>, - binary_name: &str, - fallback_paths: &[&str], -) -> Option { - // If exact provided path exists, use it - if provided_path.and_then(file_exists).is_some() { - return provided_path.cloned(); - } - - // Check if the shell we are trying to load is user's default shell - // if just use it - let default_shell_path = get_user_shell_path(); - if let Some(default_shell_path) = default_shell_path - && detect_shell_type(&default_shell_path) == Some(shell_type) - && file_exists(&default_shell_path).is_some() - { - return Some(default_shell_path); - } - - if let Ok(path) = which::which(binary_name) { - return Some(path); - } - - for path in fallback_paths { - //check exists - if let Some(path) = file_exists(&PathBuf::from(path)) { - return Some(path); +impl From for Shell { + fn from(detected: DetectedShell) -> Self { + Self { + shell_type: detected.shell_type, + shell_path: detected.shell_path, + shell_snapshot: empty_shell_snapshot_receiver(), } } - - None -} - -const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; - -fn get_zsh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Zsh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; - -fn get_bash_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Bash, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; - -fn get_sh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Sh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -// Note the `pwsh` and `powershell` fallback paths are where the respective -// shells are commonly installed on GitHub Actions Windows runners, but may not -// be present on all Windows machines: -// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell - -#[cfg(windows)] -const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; -#[cfg(not(windows))] -const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; - -#[cfg(windows)] -const POWERSHELL_FALLBACK_PATHS: &[&str] = - &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; -#[cfg(not(windows))] -const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; - -fn get_powershell_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) - .or_else(|| { - get_shell_path( - ShellType::PowerShell, - path, - "powershell", - POWERSHELL_FALLBACK_PATHS, - ) - }); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::PowerShell, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -fn get_cmd_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Cmd, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) } +#[cfg(all(test, unix))] fn ultimate_fallback_shell() -> Shell { - if cfg!(windows) { - Shell { - shell_type: ShellType::Cmd, - shell_path: PathBuf::from("cmd.exe"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } else { - Shell { - shell_type: ShellType::Sh, - shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } + codex_shell_command::shell_detect::ultimate_fallback_shell().into() } pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell { - detect_shell_type(shell_path) - .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) - .unwrap_or(ultimate_fallback_shell()) + codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into() } pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { - match shell_type { - ShellType::Zsh => get_zsh_shell(path), - ShellType::Bash => get_bash_shell(path), - ShellType::PowerShell => get_powershell_shell(path), - ShellType::Sh => get_sh_shell(path), - ShellType::Cmd => get_cmd_shell(path), - } + codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into) } pub fn default_user_shell() -> Shell { - default_user_shell_from_path(get_user_shell_path()) + codex_shell_command::shell_detect::default_user_shell().into() } +#[cfg(all(test, target_os = "macos"))] fn default_user_shell_from_path(user_shell_path: Option) -> Shell { - if cfg!(windows) { - get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or(ultimate_fallback_shell()) - } else { - let user_default_shell = user_shell_path - .and_then(|shell| detect_shell_type(&shell)) - .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); - - let shell_with_fallback = if cfg!(target_os = "macos") { - user_default_shell - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - } else { - user_default_shell - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - }; - - shell_with_fallback.unwrap_or(ultimate_fallback_shell()) - } -} - -#[cfg(test)] -mod detect_shell_type_tests { - use super::*; - - #[test] - fn test_detect_shell_type() { - assert_eq!( - detect_shell_type(&PathBuf::from("zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell")), - Some(ShellType::PowerShell) - ); - assert_eq!(detect_shell_type(&PathBuf::from("fish")), None); - assert_eq!(detect_shell_type(&PathBuf::from("other")), None); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from(if cfg!(windows) { - "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - } else { - "/usr/local/bin/pwsh" - })), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/sh")), - Some(ShellType::Sh) - ); - assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh)); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd")), - Some(ShellType::Cmd) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd.exe")), - Some(ShellType::Cmd) - ); - } + codex_shell_command::shell_detect::default_user_shell_from_path(user_shell_path).into() } #[cfg(test)] diff --git a/codex-rs/core/src/shell_detect.rs b/codex-rs/core/src/shell_detect.rs deleted file mode 100644 index 3595ab3469..0000000000 --- a/codex-rs/core/src/shell_detect.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::shell::ShellType; -use std::path::Path; -use std::path::PathBuf; - -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { - match shell_path.as_os_str().to_str() { - Some("zsh") => Some(ShellType::Zsh), - Some("sh") => Some(ShellType::Sh), - Some("cmd") => Some(ShellType::Cmd), - Some("bash") => Some(ShellType::Bash), - Some("pwsh") => Some(ShellType::PowerShell), - Some("powershell") => Some(ShellType::PowerShell), - _ => { - let shell_name = shell_path.file_stem(); - if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); - } - } - None - } - } -} diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b328a977d7..1cbd538667 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -151,9 +151,7 @@ impl ShellSnapshot { }); // Make the new snapshot. - if let Err(err) = - write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await - { + if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await { tracing::warn!( "Failed to create shell snapshot for {}: {err:?}", shell.name() @@ -203,7 +201,7 @@ async fn write_shell_snapshot( if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { bail!("Shell snapshot not supported yet for {shell_type:?}"); } - let shell = get_shell(shell_type.clone(), /*path*/ None) + let shell = get_shell(shell_type, /*path*/ None) .with_context(|| format!("No available shell for {shell_type:?}"))?; let raw_snapshot = capture_snapshot(&shell, cwd).await?; @@ -225,7 +223,7 @@ async fn write_shell_snapshot( } async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result { - let shell_type = shell.shell_type.clone(); + let shell_type = shell.shell_type; match shell_type { ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await, ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index b4b60997d6..7c492b3646 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -182,7 +182,7 @@ impl ToolExecutor for ShellCommandHandler { session.thread_id, turn.config.permissions.allow_login_shell, )?; - let shell_type = Some(session.user_shell().shell_type.clone()); + let shell_type = Some(session.user_shell().shell_type); run_exec_like(RunExecLikeArgs { tool_name, exec_params, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 6fa2e40685..ca6b2a4b3e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -122,7 +122,7 @@ pub(crate) fn get_command( let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); Ok(ResolvedCommand { command: shell.derive_exec_args(&args.cmd, use_login_shell), - shell_type: shell.shell_type.clone(), + shell_type: shell.shell_type, }) } UnifiedExecShellMode::ZshFork(zsh_fork_config) => { diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2c86deafaf..a4493cf114 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1032,7 +1032,7 @@ impl UnifiedExecProcessManager { .await; let req = UnifiedExecToolRequest { command: request.command.clone(), - shell_type: request.shell_type.clone(), + shell_type: request.shell_type, hook_command: request.hook_command.clone(), process_id: request.process_id, cwd, diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index d842094a16..eadab83fe7 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -22,6 +22,7 @@ codex-client = { workspace = true } codex-file-system = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index b6c56e9f5e..8cf18e7d1f 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -29,6 +29,7 @@ use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; use crate::process::ExecProcessEventReceiver; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_METHOD; @@ -36,6 +37,7 @@ use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -279,6 +281,12 @@ impl HttpClient for LazyRemoteExecServerClient { } } +impl LazyRemoteExecServerClient { + pub(crate) async fn environment_info(&self) -> Result { + self.get().await?.environment_info().await + } +} + #[derive(Debug, thiserror::Error)] pub enum ExecServerError { #[error("failed to spawn exec-server: {0}")] @@ -363,6 +371,10 @@ impl ExecServerClient { self.call(EXEC_METHOD, ¶ms).await } + pub async fn environment_info(&self) -> Result { + self.call(ENVIRONMENT_INFO_METHOD, &()).await + } + pub async fn read(&self, params: ReadParams) -> Result { self.call(EXEC_READ_METHOD, ¶ms).await } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 55dc031273..1dfd49d3fd 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use futures::FutureExt; +use futures::future::BoxFuture; + use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -18,8 +21,11 @@ use crate::environment_toml::environment_provider_from_codex_home; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; use crate::process::ExecBackend; +use crate::protocol::EnvironmentInfo; +use crate::protocol::ShellInfo; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; +use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; @@ -286,18 +292,49 @@ impl EnvironmentManager { pub struct Environment { exec_server_url: Option, remote_transport: Option, + info_provider: Arc, exec_backend: Arc, filesystem: Arc, http_client: Arc, local_runtime_paths: Option, } +/// Provides environment metadata from either a local environment or a remote exec-server. +trait EnvironmentInfoProvider: Send + Sync { + fn info(&self) -> BoxFuture<'_, Result>; +} + +struct LocalEnvironmentInfoProvider; + +impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + std::future::ready(Ok(EnvironmentInfo::local())).boxed() + } +} + +struct RemoteEnvironmentInfoProvider { + client: LazyRemoteExecServerClient, +} + +impl RemoteEnvironmentInfoProvider { + fn new(client: LazyRemoteExecServerClient) -> Self { + Self { client } + } +} + +impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + async move { self.client.environment_info().await }.boxed() + } +} + impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), @@ -354,6 +391,7 @@ impl Environment { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), @@ -392,6 +430,7 @@ impl Environment { Self { exec_server_url, remote_transport: Some(remote_transport), + info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())), exec_backend, filesystem, http_client: Arc::new(client), @@ -412,6 +451,11 @@ impl Environment { self.local_runtime_paths.as_ref() } + /// Returns environment information from the selected execution/filesystem environment. + pub async fn info(&self) -> Result { + self.info_provider.info().await + } + pub fn get_exec_backend(&self) -> Arc { Arc::clone(&self.exec_backend) } @@ -425,6 +469,23 @@ impl Environment { } } +impl EnvironmentInfo { + pub(crate) fn local() -> Self { + Self { + shell: codex_shell_command::shell_detect::default_user_shell().into(), + } + } +} + +impl From for ShellInfo { + fn from(shell: DetectedShell) -> Self { + Self { + name: shell.name().to_string(), + path: shell.shell_path.to_string_lossy().into_owned(), + } + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -458,6 +519,7 @@ mod tests { assert_eq!(environment.exec_server_url(), None); assert!(!environment.is_remote()); + assert!(environment.info().await.is_ok()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index dea039c288..e306ec395e 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,7 @@ pub use process::ExecProcessEvent; pub use process::ExecProcessEventReceiver; pub use process::StartedExecProcess; pub use process_id::ProcessId; +pub use protocol::EnvironmentInfo; pub use protocol::ExecClosedNotification; pub use protocol::ExecEnvPolicy; pub use protocol::ExecExitedNotification; @@ -94,6 +95,7 @@ pub use protocol::InitializeResponse; pub use protocol::ProcessOutputChunk; pub use protocol::ReadParams; pub use protocol::ReadResponse; +pub use protocol::ShellInfo; pub use protocol::TerminateParams; pub use protocol::TerminateResponse; pub use protocol::WriteParams; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 9ac80c188e..6eeeeefdd7 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -19,6 +19,7 @@ pub const EXEC_TERMINATE_METHOD: &str = "process/terminate"; pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output"; pub const EXEC_EXITED_METHOD: &str = "process/exited"; pub const EXEC_CLOSED_METHOD: &str = "process/closed"; +pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info"; pub const FS_READ_FILE_METHOD: &str = "fs/readFile"; pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile"; pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory"; @@ -64,6 +65,23 @@ pub struct InitializeResponse { pub session_id: String, } +/// Information about an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentInfo { + pub shell: ShellInfo, +} + +/// Shell detected for an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellInfo { + /// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`. + pub name: String, + /// Path the exec server would use for that shell. + pub path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecParams { diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 5456ce41c0..b8934705d2 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -14,6 +14,7 @@ use tokio_util::task::TaskTracker; use crate::ExecServerRuntimePaths; use crate::client::http_client::PendingReqwestHttpBodyStream; use crate::client::http_client::ReqwestHttpRequestRunner; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FsCanonicalizeParams; @@ -147,6 +148,11 @@ impl ExecServerHandler { session.process().exec(params).await } + pub(crate) fn environment_info(&self) -> Result { + self.require_initialized_for("environment info")?; + Ok(EnvironmentInfo::local()) + } + pub(crate) async fn exec_read( &self, params: ReadParams, diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 74adf90580..26d2876c63 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; @@ -60,6 +61,10 @@ pub(crate) fn build_router() -> RpcRouter { EXEC_METHOD, |handler: Arc, params: ExecParams| async move { handler.exec(params).await }, ); + router.request( + ENVIRONMENT_INFO_METHOD, + |handler: Arc, _params: ()| async move { handler.environment_info() }, + ); router.request( EXEC_READ_METHOD, |handler: Arc, params: ReadParams| async move { diff --git a/codex-rs/exec-server/tests/health.rs b/codex-rs/exec-server/tests/health.rs index 91b3806a22..1fcfb049b5 100644 --- a/codex-rs/exec-server/tests/health.rs +++ b/codex-rs/exec-server/tests/health.rs @@ -2,6 +2,7 @@ mod common; +use codex_exec_server::Environment; use common::exec_server::exec_server; use pretty_assertions::assert_eq; @@ -19,3 +20,17 @@ async fn exec_server_serves_readyz_alongside_websocket_endpoint() -> anyhow::Res server.shutdown().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_environment_fetches_info_from_exec_server() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + assert!(environment.is_remote()); + + let remote_info = environment.info().await?; + let local_info = Environment::default_for_tests().info().await?; + assert_eq!(remote_info, local_info); + + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index cc33d3621c..e918488f82 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -11,6 +11,7 @@ workspace = true base64 = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +libc = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 60ee5c420c..b25d3fd37c 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -100,7 +100,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { }; if !matches!(flag.as_str(), "-lc" | "-c") || !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh) ) { diff --git a/codex-rs/shell-command/src/lib.rs b/codex-rs/shell-command/src/lib.rs index 1d9e302a4e..947de5e645 100644 --- a/codex-rs/shell-command/src/lib.rs +++ b/codex-rs/shell-command/src/lib.rs @@ -1,6 +1,6 @@ //! Command parsing and safety utilities shared across Codex crates. -mod shell_detect; +pub mod shell_detect; pub mod bash; pub(crate) mod command_safety; diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index e8c9a500c4..9730439bea 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -47,7 +47,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { let shell = &command[0]; if !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::PowerShell) ) { return None; diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 34322a06d0..7068463830 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -1,8 +1,10 @@ -use std::path::Path; use std::path::PathBuf; -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum ShellType { +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub enum ShellType { Zsh, Bash, PowerShell, @@ -10,7 +12,32 @@ pub(crate) enum ShellType { Cmd, } -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { +impl ShellType { + pub fn name(self) -> &'static str { + match self { + Self::Zsh => "zsh", + Self::Bash => "bash", + Self::PowerShell => "powershell", + Self::Sh => "sh", + Self::Cmd => "cmd", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetectedShell { + pub shell_type: ShellType, + pub shell_path: PathBuf, +} + +impl DetectedShell { + pub fn name(&self) -> &'static str { + self.shell_type.name() + } +} + +pub fn detect_shell_type(shell_path: impl AsRef) -> Option { + let shell_path = shell_path.as_ref(); match shell_path.as_os_str().to_str() { Some("zsh") => Some(ShellType::Zsh), Some("sh") => Some(ShellType::Sh), @@ -21,12 +48,317 @@ pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { _ => { let shell_name = shell_path.file_stem(); if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); + let shell_name_path = std::path::Path::new(shell_name); + if shell_name_path != shell_path { + return detect_shell_type(shell_name_path); } } None } } } + +#[cfg(unix)] +fn get_user_shell_path() -> Option { + let uid = unsafe { libc::getuid() }; + use std::ffi::CStr; + use std::mem::MaybeUninit; + use std::ptr; + + let mut passwd = MaybeUninit::::uninit(); + + // We cannot use getpwuid here: it returns pointers into libc-managed + // storage, which is not safe to read concurrently on all targets (the musl + // static build used by the CLI can segfault when parallel callers race on + // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. + let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; + let buffer_len = usize::try_from(suggested_buffer_len) + .ok() + .filter(|len| *len > 0) + .unwrap_or(1024); + let mut buffer = vec![0; buffer_len]; + + loop { + let mut result = ptr::null_mut(); + let status = unsafe { + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + if result.is_null() { + return None; + } + + let passwd = unsafe { passwd.assume_init_ref() }; + if passwd.pw_shell.is_null() { + return None; + } + + let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } + .to_string_lossy() + .into_owned(); + return Some(PathBuf::from(shell_path)); + } + + if status != libc::ERANGE { + return None; + } + + // Retry with a larger buffer until libc can materialize the passwd entry. + let new_len = buffer.len().checked_mul(2)?; + if new_len > 1024 * 1024 { + return None; + } + buffer.resize(new_len, 0); + } +} + +#[cfg(not(unix))] +fn get_user_shell_path() -> Option { + None +} + +fn file_exists(path: &std::path::Path) -> Option { + if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { + Some(PathBuf::from(path)) + } else { + None + } +} + +fn get_shell_path( + shell_type: ShellType, + provided_path: Option<&PathBuf>, + binary_name: &str, + fallback_paths: &[&str], +) -> Option { + if let Some(path) = provided_path.and_then(|path| file_exists(path)) { + return Some(path); + } + + let default_shell_path = get_user_shell_path(); + if let Some(default_shell_path) = default_shell_path + && detect_shell_type(&default_shell_path) == Some(shell_type) + && file_exists(&default_shell_path).is_some() + { + return Some(default_shell_path); + } + + if let Ok(path) = which::which(binary_name) { + return Some(path); + } + + for path in fallback_paths { + if let Some(path) = file_exists(std::path::Path::new(path)) { + return Some(path); + } + } + + None +} + +const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; + +fn get_zsh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Zsh, + shell_path, + }) +} + +const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; + +fn get_bash_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Bash, + shell_path, + }) +} + +const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; + +fn get_sh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Sh, + shell_path, + }) +} + +// Note the `pwsh` and `powershell` fallback paths are where the respective +// shells are commonly installed on GitHub Actions Windows runners, but may not +// be present on all Windows machines: +// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell + +#[cfg(windows)] +const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; +#[cfg(not(windows))] +const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; + +#[cfg(windows)] +const POWERSHELL_FALLBACK_PATHS: &[&str] = + &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; +#[cfg(not(windows))] +const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; + +fn get_powershell_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) + .or_else(|| { + get_shell_path( + ShellType::PowerShell, + path, + "powershell", + POWERSHELL_FALLBACK_PATHS, + ) + }); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::PowerShell, + shell_path, + }) +} + +fn get_cmd_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Cmd, + shell_path, + }) +} + +pub fn ultimate_fallback_shell() -> DetectedShell { + if cfg!(windows) { + DetectedShell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd.exe"), + } + } else { + DetectedShell { + shell_type: ShellType::Sh, + shell_path: PathBuf::from("/bin/sh"), + } + } +} + +pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell { + detect_shell_type(shell_path) + .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) + .unwrap_or_else(ultimate_fallback_shell) +} + +pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { + match shell_type { + ShellType::Zsh => get_zsh_shell(path), + ShellType::Bash => get_bash_shell(path), + ShellType::PowerShell => get_powershell_shell(path), + ShellType::Sh => get_sh_shell(path), + ShellType::Cmd => get_cmd_shell(path), + } +} + +pub fn default_user_shell() -> DetectedShell { + default_user_shell_from_path(get_user_shell_path()) +} + +pub fn default_user_shell_from_path(user_shell_path: Option) -> DetectedShell { + if cfg!(windows) { + get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell) + } else { + let user_default_shell = user_shell_path + .and_then(|shell| detect_shell_type(&shell)) + .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); + + let shell_with_fallback = if cfg!(target_os = "macos") { + user_default_shell + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + } else { + user_default_shell + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + }; + + shell_with_fallback.unwrap_or_else(ultimate_fallback_shell) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_detect_shell_type() { + assert_eq!( + detect_shell_type(PathBuf::from("zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell")), + Some(ShellType::PowerShell) + ); + assert_eq!(detect_shell_type(PathBuf::from("fish")), None); + assert_eq!(detect_shell_type(PathBuf::from("other")), None); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from(if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + } else { + "/usr/local/bin/pwsh" + })), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/sh")), + Some(ShellType::Sh) + ); + assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh)); + assert_eq!( + detect_shell_type(PathBuf::from("cmd")), + Some(ShellType::Cmd) + ); + assert_eq!( + detect_shell_type(PathBuf::from("cmd.exe")), + Some(ShellType::Cmd) + ); + } +} From 5f4d06ef186b896d316620556e561d59206c3ebf Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 10:25:57 +0200 Subject: [PATCH 057/141] Encrypt multi-agent v2 message payloads (#26210) ## Why Multi-agent v2 currently routes agent instructions through normal tool arguments and inter-agent context. That means the parent model can emit plaintext task text, Codex can persist it in history/rollouts, and the recipient can receive it as ordinary assistant-message JSON. This changes the v2 path so agent instructions stay encrypted between model calls: Responses encrypts the `message` argument returned by the model, Codex forwards only that ciphertext, and Responses decrypts it internally for the recipient model. ## What changed - Mark the v2 `message` parameter as encrypted for `spawn_agent`, `send_message`, and `followup_task`. - Treat multi-agent v2 tool `message` values as ciphertext unconditionally. - Store v2 inter-agent task text in `InterAgentCommunication.encrypted_content` with empty plaintext `content`. - Convert encrypted inter-agent communications into the Responses `agent_message` input item before sending the child request. - Preserve `agent_message` items across history, rollout, compaction, telemetry, and app-server schema paths. - Leave multi-agent v1 unchanged. ## Message shape The model still calls the v2 tools with a `message` argument, but that value is now ciphertext: ```json { "name": "spawn_agent", "arguments": { "task_name": "worker", "message": "" } } ``` Codex stores the task as encrypted inter-agent communication: ```json { "author": "/root", "recipient": "/root/worker", "content": "", "encrypted_content": "", "trigger_turn": true } ``` When Codex builds the recipient request, it forwards the ciphertext using the new Responses input item: ```json { "type": "agent_message", "author": "/root", "recipient": "/root/worker", "content": [ { "type": "encrypted_content", "encrypted_content": "" } ] } ``` Responses decrypts that item internally for the recipient model. ## Context impact - Parent context no longer carries plaintext v2 agent task instructions from these tool arguments. - Codex rollout/history stores ciphertext for v2 agent instructions. - Recipient requests receive an `agent_message` item instead of assistant commentary JSON for encrypted task delivery. - Plaintext completion/status notifications are still plaintext because they are Codex-generated status messages, not encrypted model tool arguments. ## Validation - `just test -p codex-tools` - `just test -p codex-protocol` - `just test -p codex-rollout` - `just test -p codex-rollout-trace` - `just test -p codex-otel` - `just write-app-server-schema` --- .../schema/json/ClientRequest.json | 55 ++++++++++++++ .../PermissionsRequestApprovalParams.json | 6 +- .../schema/json/ServerRequest.json | 6 +- .../codex_app_server_protocol.schemas.json | 55 ++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 55 ++++++++++++++ .../RawResponseItemCompletedNotification.json | 55 ++++++++++++++ .../schema/json/v2/ThreadResumeParams.json | 55 ++++++++++++++ .../typescript/AgentMessageInputContent.ts | 5 ++ .../schema/typescript/ResponseItem.ts | 3 +- .../schema/typescript/index.ts | 1 + codex-rs/core/src/agent/control.rs | 39 ++++++++-- codex-rs/core/src/agent/control_tests.rs | 56 ++++++++++++++ codex-rs/core/src/agent/registry.rs | 14 ++++ codex-rs/core/src/client_common.rs | 18 ++++- codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/context_manager/history.rs | 8 +- codex-rs/core/src/session/turn.rs | 1 + .../src/tools/handlers/multi_agents_spec.rs | 11 ++- .../tools/handlers/multi_agents_spec_tests.rs | 24 ++++++ .../src/tools/handlers/multi_agents_tests.rs | 25 ++++--- .../src/tools/handlers/multi_agents_v2.rs | 15 ++++ .../handlers/multi_agents_v2/message_tool.rs | 21 +++--- .../tools/handlers/multi_agents_v2/spawn.rs | 22 ++---- codex-rs/core/src/tools/spec_plan_tests.rs | 24 ++++++ codex-rs/core/src/turn_timing.rs | 1 + .../tests/suite/subagent_notifications.rs | 75 +++++++++++++++++++ codex-rs/ext/image-generation/src/tool.rs | 1 + codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/models.rs | 11 +++ codex-rs/protocol/src/protocol.rs | 36 +++++++++ .../rollout-trace/src/reducer/tool/agents.rs | 7 +- codex-rs/rollout/src/policy.rs | 4 +- codex-rs/tools/src/json_schema.rs | 8 ++ codex-rs/tools/src/json_schema_tests.rs | 14 ++++ 34 files changed, 674 insertions(+), 59 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 861ae1dcbd..c6e1aa3302 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -34,6 +34,30 @@ ], "type": "string" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -2120,6 +2144,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 961e24c2c2..f2ab783342 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -288,8 +288,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -326,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 13bac2e118..dbfca64f4c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1593,8 +1593,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -2005,4 +2005,4 @@ } ], "title": "ServerRequest" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4802a52300..bbcbb20be4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5900,6 +5900,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -14050,6 +14074,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/v2/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2b9e30bae7..65d999b334 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -265,6 +265,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -10572,6 +10596,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index f82acbae64..c18f9aeb15 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ContentItem": { "oneOf": [ { @@ -369,6 +393,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 364cb36617..a6abcc5665 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -436,6 +460,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts new file mode 100644 index 0000000000..a3bb645597 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index e5e960ff81..b90963db80 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; import type { LocalShellAction } from "./LocalShellAction"; @@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 458d2e43b9..149b3aec0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; +export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 549852e360..6317dff3b5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -109,7 +109,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -715,7 +716,12 @@ impl AgentControl { agent_id: ThreadId, initial_operation: Op, ) -> CodexResult { - let last_task_message = render_input_preview(&initial_operation); + let last_task_message = match &initial_operation { + Op::InterAgentCommunication { communication } => { + last_task_message_from_communication(communication) + } + _ => non_empty_task_message(render_input_preview(&initial_operation)), + }; let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -725,8 +731,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -736,7 +746,7 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { - let last_task_message = communication.content.clone(); + let last_task_message = last_task_message_from_communication(&communication); let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -748,8 +758,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -1329,6 +1343,17 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { } } +fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option { + if communication.encrypted_content.is_some() { + return None; + } + non_empty_task_message(communication.content.clone()) +} + +fn non_empty_task_message(message: String) -> Option { + (!message.is_empty()).then_some(message) +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index f9462e94f6..3866d22013 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,62 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("old plaintext task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + Some("old plaintext task".to_string()) + ); + + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root(), + agent_path, + Vec::new(), + "encrypted-task".to_string(), + /*trigger_turn*/ true, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication) + .await + .expect("send_inter_agent_communication should succeed"); + + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + None + ); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index 1acd73085f..43aca201bf 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -180,6 +180,20 @@ impl AgentRegistry { } } + pub(crate) fn clear_last_task_message(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = None; + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d775f9c01c..48d0523df4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -3,6 +3,7 @@ use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use codex_tools::ToolSpec; use futures::Stream; use serde_json::Value; @@ -53,7 +54,22 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - self.input.clone() + self.input + .iter() + .cloned() + .map(|item| { + let ResponseItem::Message { role, content, .. } = &item else { + return item; + }; + if role != "assistant" { + return item; + } + InterAgentCommunication::from_message_content(content) + .filter(|communication| communication.encrypted_content.is_some()) + .map(|communication| communication.to_model_input_item()) + .unwrap_or(item) + }) + .collect() } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index a480c1eba8..c7730ddfb6 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -348,6 +348,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, + ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0be9bbc18e..b8bcf9c846 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,6 +386,7 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), }, ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -474,7 +475,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } @@ -720,11 +722,15 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Other => false, } } pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return true; + } let ResponseItem::Message { role, content, .. } = item else { return false; }; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4a9316496c..996e59187e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1924,6 +1924,7 @@ async fn try_run_sampling_request( role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) } ResponseItem::Reasoning { .. } => true, + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index a15464d9df..891ac5a592 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -167,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to queue on the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -199,7 +200,8 @@ pub fn create_followup_task_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to send to the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -595,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap InterAgentCommunication { + InterAgentCommunication::new_encrypted( + author, + recipient, + Vec::new(), + message, + /*trigger_turn*/ true, + ) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 258d80c85b..f23d700e9e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -1,4 +1,4 @@ -//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. +//! Shared argument parsing and dispatch for the v2 agent messaging tools. //! //! `send_message` and `followup_task` share the same submission path and differ only in whether the //! resulting `InterAgentCommunication` should wake the target immediately. @@ -55,20 +55,21 @@ fn message_content(message: String) -> Result { Ok(message) } -/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`. +/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`. pub(crate) async fn handle_message_string_tool( invocation: ToolInvocation, mode: MessageDeliveryMode, target: String, message: String, ) -> Result { - let prompt = message_content(message)?; + let message = message_content(message)?; let ToolInvocation { session, turn, call_id, .. } = invocation; + let prompt = String::new(); let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services @@ -101,15 +102,11 @@ pub(crate) async fn handle_message_string_tool( let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) })?; - let communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ); + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, receiver_agent_path, message); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 7d5b469a45..b5b7d210f5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; -use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; @@ -9,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -61,8 +59,9 @@ async fn handle_spawn_agent( .map(str::trim) .filter(|role| !role.is_empty()); + let message = args.message.clone(); let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let prompt = String::new(); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); @@ -129,17 +128,12 @@ async fn handle_spawn_agent( .iter() .all(|item| matches!(item, UserInput::Text { .. })) => { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, recipient, message); + Op::InterAgentCommunication { communication } } (_, initial_operation) => initial_operation, }, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bf5b1c01bb..381accb2f6 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1047,6 +1047,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn multi_agent_v2_message_schemas_are_encrypted() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + }) + .await; + for tool_name in ["spawn_agent", "send_message", "followup_task"] { + let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { + panic!("expected {tool_name} function spec"); + }; + let properties = tool + .parameters + .properties + .as_ref() + .expect("tool should use object params"); + assert_eq!( + properties + .get("message") + .and_then(|schema| schema.encrypted), + Some(true) + ); + } +} + #[tokio::test] async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 570e4a6e32..bcb3cb8b9e 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -177,6 +177,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { }) }) } + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c39a6bb39d..97d60d67c9 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,6 +17,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -1026,6 +1027,80 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { + let server = start_mock_server().await; + let encrypted_message = "opaque-encrypted-message"; + let spawn_args = serde_json::to_string(&json!({ + "message": encrypted_message, + "task_name": "worker", + }))?; + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-parent-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-parent-1"), + ]), + ) + .await; + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("resp-child-1"), + ev_completed("resp-child-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("resp-parent-2"), + ev_assistant_message("msg-parent-2", "done"), + ev_completed("resp-parent-2"), + ]), + ) + .await; + + let mut builder = test_codex().with_model("koffing").with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + + let child_request = wait_for_requests(&child_request_log) + .await? + .pop() + .expect("child request"); + assert_eq!( + child_request.inputs_of_type("agent_message"), + vec![json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "encrypted_content", + "encrypted_content": encrypted_message, + }], + })] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 9c73e96b23..41c06c8007 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -242,6 +242,7 @@ fn edit_images(history: &[ResponseItem]) -> Vec { )); } ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 0003cb04a5..65ca0c4530 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1217,6 +1217,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { ResponseItem::Message { role, .. } => format!("message_from_{role}"), + ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d95370c061..517c33cac9 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -715,6 +715,12 @@ pub enum ContentItem { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMessageInputContent { + EncryptedContent { encrypted_content: String }, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { @@ -758,6 +764,11 @@ pub enum ResponseItem { #[ts(optional)] phase: Option, }, + AgentMessage { + author: String, + recipient: String, + content: Vec, + }, Reasoning { #[serde(default, skip_serializing)] #[ts(skip)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91ef624fa7..b3538a27a1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::CallToolResult; use crate::mcp::RequestId; use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; +use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; @@ -690,6 +691,9 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub encrypted_content: Option, pub trigger_turn: bool, } @@ -706,6 +710,24 @@ impl InterAgentCommunication { recipient, other_recipients, content, + encrypted_content: None, + trigger_turn, + } + } + + pub fn new_encrypted( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + encrypted_content: String, + trigger_turn: bool, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content: String::new(), + encrypted_content: Some(encrypted_content), trigger_turn, } } @@ -720,6 +742,19 @@ impl InterAgentCommunication { } } + pub fn to_model_input_item(&self) -> ResponseItem { + match &self.encrypted_content { + Some(encrypted_content) => ResponseItem::AgentMessage { + author: self.author.to_string(), + recipient: self.recipient.to_string(), + content: vec![AgentMessageInputContent::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }], + }, + None => self.to_response_input_item().into(), + } + } + pub fn is_message_content(content: &[ContentItem]) -> bool { Self::from_message_content(content).is_some() } @@ -4063,6 +4098,7 @@ mod tests { recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), + encrypted_content: None, trigger_turn: true, }; diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index a49b794d14..37f3220c2c 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -613,7 +613,12 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String return None; }; let communication = serde_json::from_str::(text).ok()?; - Some((communication.recipient.to_string(), communication.content)) + Some(( + communication.recipient.to_string(), + communication + .encrypted_content + .unwrap_or(communication.content), + )) } #[cfg(test)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 92e5f5690b..d1a0045cd9 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -30,6 +30,7 @@ pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec { pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -60,7 +61,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 1b53ad9803..5352eaa91d 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -43,6 +43,9 @@ pub struct JsonSchema { pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Responses-only marker for reviewed encrypted tool parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] pub enum_values: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +93,11 @@ impl JsonSchema { Self::typed(JsonSchemaPrimitiveType::String, description) } + pub fn with_encrypted(mut self) -> Self { + self.encrypted = Some(true); + self + } + pub fn number(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Number, description) } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 9315d43c00..6973d06c00 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -24,6 +24,20 @@ fn parse_tool_input_schema_coerces_boolean_schemas() { assert_eq!(schema, JsonSchema::string(/*description*/ None)); } +#[test] +fn json_schema_serializes_encrypted_marker() { + let schema = JsonSchema::string(Some("Secret value".to_string())).with_encrypted(); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "string", + "description": "Secret value", + "encrypted": true, + }) + ); +} + #[test] fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // Example schema shape: From 6dc28ba6e0acd391a1a006af8bd0237b61fd54a6 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 11:10:32 +0200 Subject: [PATCH 058/141] nit: doc (#26566) Matching CBv9 --- codex-rs/core/src/tools/handlers/multi_agents_spec.rs | 2 +- codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 891ac5a592..1dcb55f436 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -207,7 +207,7 @@ pub fn create_followup_task_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "followup_task".to_string(), - description: "Send a follow-up task to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes." + description: "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." .to_string(), strict: false, defer_loading: None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 0ae94079e9..512e318a82 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -296,6 +296,7 @@ fn send_message_tool_requires_message_and_has_no_output_schema() { fn followup_task_tool_requires_message_and_has_no_output_schema() { let ToolSpec::Function(ResponsesApiTool { name, + description, parameters, output_schema, .. @@ -304,6 +305,10 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() { panic!("followup_task should be a function tool"); }; assert_eq!(name, "followup_task"); + assert_eq!( + description, + "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." + ); assert_eq!( parameters.schema_type, Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)) From 55aa071b17c825bdb66fac99cde2e7a7acfbdee7 Mon Sep 17 00:00:00 2001 From: carlc-oai Date: Fri, 5 Jun 2026 02:41:06 -0700 Subject: [PATCH 059/141] [codex] Forward turn moderation metadata through app-server (#25710) ## Why First-party backends can supply turn-scoped moderation metadata that app-server clients need for client-side presentation. Exposing this as an experimental typed notification lets opted-in clients consume it without interpreting raw Responses API events. ## What changed - forward `response.metadata.openai_chatgpt_moderation_metadata` from Responses API SSE and WebSocket streams as turn-scoped moderation metadata - emit the experimental app-server v2 `turn/moderationMetadata` notification with `{ threadId, turnId, metadata }` - add app-server integration coverage for the typed moderation metadata notification ## Testing - `just test -p codex-core build_ws_client_metadata_includes_window_lineage_and_turn_metadata` - `just test -p codex-core` (fails locally: 46 failures and 1 timeout, primarily missing `test_stdio_server` and shell snapshot timeouts) - `just test -p codex-app-server-protocol` - `just test -p codex-app-server turn_moderation_metadata_emits_typed_notification_v2` - `just test -p codex-app-server` (fails locally: 792 passed, 10 failed, and 5 timed out; failures are in existing environment-sensitive tests, primarily because nested macOS `sandbox-exec` is not permitted) - `just write-app-server-schema --experimental --schema-root /tmp/codex-app-server-schema-experimental` --- .../schema/json/ServerNotification.json | 37 +++++++++ .../codex_app_server_protocol.schemas.json | 39 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 39 +++++++++ .../TurnModerationMetadataNotification.json | 19 +++++ .../schema/typescript/ServerNotification.ts | 3 +- .../v2/TurnModerationMetadataNotification.ts | 6 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 17 ++++ .../src/protocol/v2/model.rs | 10 +++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 11 +++ codex-rs/app-server/src/outgoing_message.rs | 26 ++++++ .../tests/suite/v2/safety_check_downgrade.rs | 82 +++++++++++++++++++ codex-rs/codex-api/src/common.rs | 3 + .../src/endpoint/responses_websocket.rs | 11 +++ codex-rs/codex-api/src/sse/responses.rs | 57 +++++++++++++ codex-rs/core/src/session/mod.rs | 10 +++ codex-rs/core/src/session/turn.rs | 5 ++ codex-rs/core/src/turn_timing.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 +- codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 ++ codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 26 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 16656e3a55..010ec33e08 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4909,6 +4909,23 @@ } ] }, + "TurnModerationMetadataNotification": { + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { @@ -6248,6 +6265,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bbcbb20be4..10bde05597 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4904,6 +4904,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -18601,6 +18621,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 65d999b334..e4c91e7586 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12420,6 +12420,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -16418,6 +16438,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json new file mode 100644 index 0000000000..273bd41061 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 3ed710efc0..2520c71c3c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -61,6 +61,7 @@ import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageU import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification"; import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; +import type { TurnModerationMetadataNotification } from "./v2/TurnModerationMetadataNotification"; import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; import type { WarningNotification } from "./v2/WarningNotification"; @@ -70,4 +71,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts new file mode 100644 index 0000000000..1d46d1b4b8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b4c65946d5..bb19c92bdf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -447,6 +447,7 @@ export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; export type { TurnItemsView } from "./TurnItemsView"; +export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a60d8d002..47410551cf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1549,6 +1549,8 @@ server_notification_definitions! { ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), ModelVerification => "model/verification" (v2::ModelVerificationNotification), + #[experimental("turn/moderationMetadata")] + TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -3222,6 +3224,21 @@ mod tests { ); } + #[test] + fn turn_moderation_metadata_notification_is_marked_experimental() { + let notification = + ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("turn/moderationMetadata") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index 7f97d791a6..d8fa249582 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; use ts_rs::TS; v2_enum_from_core!( @@ -152,3 +153,12 @@ pub struct ModelVerificationNotification { pub turn_id: String, pub verifications: Vec, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnModerationMetadataNotification { + pub thread_id: String, + pub turn_id: String, + pub metadata: JsonValue, +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6d705afa98..d112eeff9d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1244,6 +1244,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. +- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f4a5f3b45f..0cec31b6f2 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; @@ -337,6 +338,16 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::ModelVerification(notification)) .await; } + EventMsg::TurnModerationMetadata(event) => { + let notification = TurnModerationMetadataNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + metadata: event.metadata, + }; + outgoing + .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index ac7e87741a..2e67e23398 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -725,6 +725,7 @@ mod tests { use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; use serde_json::json; @@ -951,6 +952,31 @@ mod tests { ); } + #[test] + fn verify_turn_moderation_metadata_notification_serialization() { + let notification = + ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "turn/moderationMetadata", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "metadata": {"presentation": "inline"}, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn server_request_response_from_result_decodes_typed_response() { let request = ServerRequest::CommandExecutionRequestApproval { diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index c5b70b2334..ea3cd8dada 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; @@ -309,6 +310,87 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< Ok(()) } +#[tokio::test] +async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + serde_json::json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "trigger moderation metadata".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/moderationMetadata"), + ) + .await??; + let metadata: TurnModerationMetadataNotification = + serde_json::from_value(notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/moderationMetadata notifications must include params") + })?)?; + assert_eq!( + metadata, + TurnModerationMetadataNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + metadata: serde_json::json!({"presentation": "inline"}), + } + ); + + Ok(()) +} + async fn collect_turn_notifications_and_validate_no_warning_item( mcp: &mut TestAppServer, ) -> Result { diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 12f8cc55da..17753e799a 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::W3cTraceContext; use futures::Stream; use serde::Deserialize; @@ -78,6 +79,8 @@ pub enum ResponseEvent { ServerModel(String), /// Emitted when the server recommends additional account verification. ModelVerifications(Vec), + /// Emitted when the server includes moderation metadata for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index a7366a9871..44cb6a544b 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -683,6 +683,7 @@ async fn run_websocket_response_stream( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -707,6 +708,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index c9f35e5a4e..ab1be640df 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -8,6 +8,7 @@ use codex_client::StreamResponse; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use eventsource_stream::Eventsource; use futures::StreamExt; use serde::Deserialize; @@ -193,6 +194,18 @@ impl ResponsesStreamEvent { .and_then(|metadata| metadata.get("openai_verification_recommendation")) .and_then(model_verifications_from_json_value) } + + pub(crate) fn turn_moderation_metadata(&self) -> Option { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_chatgpt_moderation_metadata")) + .cloned() + .map(|metadata| TurnModerationMetadataEvent { metadata }) + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -444,6 +457,7 @@ pub async fn process_sse( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -465,6 +479,14 @@ pub async fn process_sse( { return; } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1215,6 +1237,41 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_turn_moderation_metadata_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::TurnModerationMetadata(result) + if result.metadata == json!({"presentation": "inline"}) + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9aeca1991b..1b40bd7656 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -359,6 +359,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolEnvironmentMode; @@ -2637,6 +2638,15 @@ impl Session { .await; } + pub(crate) async fn emit_turn_moderation_metadata( + self: &Arc, + turn_context: &Arc, + metadata: TurnModerationMetadataEvent, + ) { + self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata)) + .await; + } + pub(crate) async fn replace_history( &self, items: Vec, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 996e59187e..63195d9fa7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1392,6 +1392,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2057,6 +2058,10 @@ async fn try_run_sampling_request( .await; } } + ResponseEvent::TurnModerationMetadata(metadata) => { + sess.emit_turn_moderation_metadata(&turn_context, metadata) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index bcb3cb8b9e..445fe16214 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -147,6 +147,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { ResponseEvent::Created | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) + | ResponseEvent::TurnModerationMetadata(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 05b995e721..fd56805d4e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,7 +266,8 @@ async fn run_codex_tool_session_inner( } EventMsg::Warning(_) | EventMsg::GuardianWarning(_) - | EventMsg::ModelVerification(_) => { + | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) => { continue; } EventMsg::GuardianAssessment(_) => { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 65ca0c4530..00c13046dd 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1208,6 +1208,7 @@ impl SessionTelemetry { } ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), + ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b3538a27a1..ed278d1843 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1221,6 +1221,9 @@ pub enum EventMsg { /// Backend recommends additional account verification for this turn. ModelVerification(ModelVerificationEvent), + /// Backend moderation metadata intended for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1885,6 +1888,11 @@ pub struct ModelVerificationEvent { pub verifications: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TurnModerationMetadataEvent { + pub metadata: Value, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a862af116c..a3e1f184ec 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -224,6 +224,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::RealtimeConversationSdp(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index d1a0045cd9..17da9e6203 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -117,6 +117,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 0b2143b429..ea9fce90fb 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -118,6 +118,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::TurnModerationMetadata(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadRealtimeStarted(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 6d56330367..1ddbe40d54 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -233,6 +233,7 @@ impl ChatWidget { | ServerNotification::RemoteControlStatusChanged(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeTranscriptDelta(_) From 66232220e21712604b0066f496e966c9a2c1bda8 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 14:52:51 +0200 Subject: [PATCH 060/141] [codex] Keep v1 spawn metadata visible (#26599) ## Summary - keep the legacy v1 `spawn_agent` role and model selectors visible - add regression coverage for the default v1 tool plan ## Why `hide_spawn_agent_metadata` is a multi-agent v2 setting, but the v1 planning branch also consumed it. After the default changed to `true`, v1 stopped advertising `agent_type`, `model`, `reasoning_effort`, and `service_tier`, preventing configured agents from being selected. This keeps the hidden-metadata default for v2 while opting v1 out of that behavior. Fixes #26363. ## Validation Not run locally, per request; CI will validate the change. --- codex-rs/core/src/tools/spec_plan.rs | 5 +---- codex-rs/core/src/tools/spec_plan_tests.rs | 24 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index eae0644b83..47d2ac6726 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -762,10 +762,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: turn_context - .config - .multi_agent_v2 - .hide_spawn_agent_metadata, + hide_agent_type_model_reasoning: false, include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), max_concurrent_threads_per_session: max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 381accb2f6..17dfdc7a74 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1003,6 +1003,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { "wait_agent".to_string(), ] ); + let ToolSpec::Namespace(namespace) = v1.visible_spec(MULTI_AGENT_V1_NAMESPACE) else { + panic!("expected v1 multi-agent namespace"); + }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected v1 spawn_agent function"); + }; + let properties = spawn_agent + .parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + for property in ["agent_type", "model", "reasoning_effort", "service_tier"] { + assert!( + properties.contains_key(property), + "expected v1 spawn_agent to expose `{property}`" + ); + } let v2 = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); From 0b1512c2c83935af4eaa096ac7e434992f15b066 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 16:24:22 +0200 Subject: [PATCH 061/141] refactor: split agent control modules (#26610) ## Summary Mechanically splits `AgentControl` into focused modules so later agent runtime changes are easier to review. The shared lookup, messaging, and completion logic remains in `control.rs`, while spawn-specific code and V1 legacy close/resume behavior move into dedicated files. ## Changes - Extract spawn-agent code into `agent/control/spawn.rs`. - Extract V1-only legacy close/resume behavior into `agent/control/legacy.rs`. - Keep shared control-plane behavior in `agent/control.rs`. - Preserve existing behavior; this PR is intended to be mechanical. ## Stack 1. This PR - Mechanical `AgentControl` split: extracts spawn and V1 legacy code without behavior changes. 2. #26614 - Execution slot accounting: separates logical agents from active execution slots. 3. #26611 - Residency and reload runtime: adds resident-agent LRU, eviction/reload, durable lookup, and V2 delivery through reload. 4. #26612 - V2 tool semantics: narrows `close_agent` to interrupt-only and updates V2 tool coverage. --- codex-rs/core/src/agent/control.rs | 687 +--------------------- codex-rs/core/src/agent/control/legacy.rs | 83 +++ codex-rs/core/src/agent/control/spawn.rs | 606 +++++++++++++++++++ 3 files changed, 693 insertions(+), 683 deletions(-) create mode 100644 codex-rs/core/src/agent/control/legacy.rs create mode 100644 codex-rs/core/src/agent/control/spawn.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 6317dff3b5..6c6650a559 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -42,9 +42,11 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; -const AGENT_NAMES: &str = include_str!("agent_names.txt"); const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; +mod legacy; +mod spawn; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SpawnAgentForkMode { FullHistory, @@ -59,11 +61,6 @@ pub(crate) struct SpawnAgentOptions { pub(crate) environments: Option>, } -struct SpawnAgentThreadInheritance { - shell_snapshot: Option>, - exec_policy: Option>, -} - #[derive(Clone, Debug)] pub(crate) struct LiveAgent { pub(crate) thread_id: ThreadId, @@ -78,77 +75,6 @@ pub(crate) struct ListedAgent { pub(crate) last_task_message: Option, } -fn default_agent_nickname_list() -> Vec<&'static str> { - AGENT_NAMES - .lines() - .map(str::trim) - .filter(|name| !name.is_empty()) - .collect() -} - -fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { - let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); - if let Some(candidates) = - resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) - { - return candidates; - } - - default_agent_nickname_list() - .into_iter() - .map(ToOwned::to_owned) - .collect() -} - -fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { - match item { - RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() - { - "system" | "developer" | "user" => true, - "assistant" => *phase == Some(MessagePhase::FinalAnswer), - _ => false, - }, - RolloutItem::ResponseItem( - ResponseItem::AgentMessage { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::CompactionTrigger - | ResponseItem::ContextCompaction { .. } - | ResponseItem::Other, - ) => false, - // Full-history forks preserve the cached prompt prefix and can keep diffing - // from the parent's durable baseline. Truncated forks drop part of that prompt, - // so they must rebuild context on their first child turn. - RolloutItem::TurnContext(_) => preserve_reference_context_item, - RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, - } -} - -fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - if role != "developer" { - return false; - } - let [ContentItem::InputText { text }] = content.as_slice() else { - return false; - }; - - usage_hint_texts - .iter() - .any(|usage_hint_text| usage_hint_text == text) -} - /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -185,531 +111,6 @@ impl AgentControl { self.session_id } - /// Spawn a new agent thread and submit the initial prompt. - #[cfg(test)] - pub(crate) async fn spawn_agent( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - ) -> CodexResult { - let spawned_agent = Box::pin(self.spawn_agent_internal( - config, - initial_operation, - session_source, - SpawnAgentOptions::default(), - )) - .await?; - Ok(spawned_agent.thread_id) - } - - /// Spawn an agent thread with some metadata. - pub(crate) async fn spawn_agent_with_metadata( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - options: SpawnAgentOptions, // TODO(jif) drop with new fork. - ) -> CodexResult { - Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) - .await - } - - async fn spawn_agent_internal( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - options: SpawnAgentOptions, - ) -> CodexResult { - let state = self.upgrade()?; - let multi_agent_version = state - .effective_multi_agent_version_for_spawn( - &InitialHistory::New, - session_source.as_ref(), - options.parent_thread_id, - /*forked_from_thread_id*/ None, - &config, - ) - .await; - let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; - let inheritance = SpawnAgentThreadInheritance { - shell_snapshot: self - .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) - .await, - exec_policy: self - .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) - .await, - }; - let (session_source, mut agent_metadata) = match session_source { - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_role, - .. - })) => { - let (session_source, agent_metadata) = self.prepare_thread_spawn( - &mut reservation, - &config, - parent_thread_id, - depth, - agent_path, - agent_role, - /*preferred_agent_nickname*/ None, - )?; - (Some(session_source), agent_metadata) - } - other => (other, AgentMetadata::default()), - }; - let notification_source = session_source.clone(); - - // The same `AgentControl` is sent to spawn the thread. - let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { - (Some(session_source), Some(_), inheritance) => { - Box::pin(self.spawn_forked_thread( - &state, - config, - session_source, - &options, - inheritance, - multi_agent_version, - )) - .await? - } - (Some(session_source), None, inheritance) => { - Box::pin(state.spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - options.parent_thread_id, - /*forked_from_thread_id*/ None, - /*thread_source*/ Some(ThreadSource::Subagent), - /*metrics_service_name*/ None, - inheritance.shell_snapshot, - inheritance.exec_policy, - options.environments.clone(), - )) - .await? - } - (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, - }; - agent_metadata.agent_id = Some(new_thread.thread_id); - reservation.commit(agent_metadata.clone()); - - if let Some(SessionSource::SubAgent( - subagent_source @ SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }, - )) = notification_source.as_ref() - { - let client_metadata = match state.get_thread(*parent_thread_id).await { - Ok(parent_thread) => { - parent_thread - .codex - .session - .app_server_client_metadata() - .await - } - Err(error) => { - tracing::warn!( - error = %error, - parent_thread_id = %parent_thread_id, - "skipping subagent thread analytics: failed to load parent thread metadata" - ); - crate::session::session::AppServerClientMetadata { - client_name: None, - client_version: None, - } - } - }; - let thread_config = new_thread.thread.codex.thread_config_snapshot().await; - let parent_thread_id = thread_config.parent_thread_id; - emit_subagent_session_started( - &new_thread - .thread - .codex - .session - .services - .analytics_events_client, - client_metadata, - new_thread.thread.codex.session.session_id(), - new_thread.thread_id, - parent_thread_id, - thread_config, - subagent_source.clone(), - ); - } - - // Notify a new thread has been created. This notification will be processed by clients - // to subscribe or drain this newly created thread. - // TODO(jif) add helper for drain - state.notify_thread_created(new_thread.thread_id); - - self.persist_thread_spawn_edge_for_source( - new_thread.thread.as_ref(), - new_thread.thread_id, - notification_source.as_ref(), - ) - .await; - - self.send_input(new_thread.thread_id, initial_operation) - .await?; - if multi_agent_version != MultiAgentVersion::V2 { - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| new_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - new_thread.thread_id, - notification_source, - child_reference, - agent_metadata.agent_path.clone(), - ); - } - - Ok(LiveAgent { - thread_id: new_thread.thread_id, - metadata: agent_metadata, - status: self.get_status(new_thread.thread_id).await, - }) - } - - async fn spawn_forked_thread( - &self, - state: &Arc, - config: Config, - session_source: SessionSource, - options: &SpawnAgentOptions, - inheritance: SpawnAgentThreadInheritance, - multi_agent_version: MultiAgentVersion, - ) -> CodexResult { - let SpawnAgentThreadInheritance { - shell_snapshot: inherited_shell_snapshot, - exec_policy: inherited_exec_policy, - } = inheritance; - if options.fork_parent_spawn_call_id.is_none() { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a parent spawn call id".to_string(), - )); - } - let Some(fork_mode) = options.fork_mode.as_ref() else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a fork mode".to_string(), - )); - }; - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) = &session_source - else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a thread-spawn session source".to_string(), - )); - }; - - let parent_thread_id = *parent_thread_id; - let parent_thread = state.get_thread(parent_thread_id).await.ok(); - if let Some(parent_thread) = parent_thread.as_ref() { - // `record_conversation_items` only queues persistence writes asynchronously. - // Flush before snapshotting store history for a fork. - parent_thread.ensure_rollout_materialized().await; - parent_thread.flush_rollout().await?; - } - - let parent_history = state - .read_stored_thread(ReadThreadParams { - thread_id: parent_thread_id, - include_archived: true, - include_history: true, - }) - .await? - .history - .ok_or_else(|| { - CodexErr::Fatal(format!( - "parent thread history unavailable for fork: {parent_thread_id}" - )) - })?; - - let mut forked_rollout_items = parent_history.items; - if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { - forked_rollout_items = - truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); - } - let multi_agent_v2_usage_hint_texts_to_filter: Vec = - if let Some(parent_thread) = parent_thread.as_ref() { - if multi_agent_version == MultiAgentVersion::V2 { - let parent_config = parent_thread.codex.session.get_config().await; - [ - parent_config - .multi_agent_v2 - .root_agent_usage_hint_text - .clone(), - parent_config - .multi_agent_v2 - .subagent_usage_hint_text - .clone(), - ] - .into_iter() - .flatten() - .collect() - } else { - Vec::new() - } - } else if multi_agent_version == MultiAgentVersion::V2 { - [ - config.multi_agent_v2.root_agent_usage_hint_text.clone(), - config.multi_agent_v2.subagent_usage_hint_text.clone(), - ] - .into_iter() - .flatten() - .collect() - } else { - Vec::new() - }; - let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); - forked_rollout_items.retain(|item| { - keep_forked_rollout_item(item, preserve_reference_context_item) - && !matches!( - item, - RolloutItem::ResponseItem(response_item) - if is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - ) - }); - for item in &mut forked_rollout_items { - if let RolloutItem::Compacted(compacted) = item - && let Some(replacement_history) = compacted.replacement_history.as_mut() - { - replacement_history.retain(|response_item| { - !is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - }); - } - } - if preserve_reference_context_item - && multi_agent_version == MultiAgentVersion::V2 - && config.multi_agent_v2.usage_hint_enabled - && let Some(subagent_usage_hint_text) = - config.multi_agent_v2.subagent_usage_hint_text.clone() - && let Some(subagent_usage_hint_message) = - crate::context_manager::updates::build_developer_update_item(vec![ - subagent_usage_hint_text, - ]) - { - forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); - } - - state - .fork_thread_with_source( - config.clone(), - InitialHistory::Forked(forked_rollout_items), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*parent_thread_id*/ Some(parent_thread_id), - /*forked_from_thread_id*/ Some(parent_thread_id), - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await - } - - /// Resume an existing agent thread from a recorded rollout file. - pub(crate) async fn resume_agent_from_rollout( - &self, - config: Config, - thread_id: ThreadId, - session_source: SessionSource, - ) -> CodexResult { - let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); - let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - thread_id, - session_source, - )) - .await?; - let state = self.upgrade()?; - let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { - return Ok(resumed_thread_id); - }; - let Some(state_db_ctx) = resumed_thread.state_db() else { - return Ok(resumed_thread_id); - }; - - let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); - while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { - let child_ids = match state_db_ctx - .list_thread_spawn_children_with_status( - parent_thread_id, - DirectionalThreadSpawnEdgeStatus::Open, - ) - .await - { - Ok(child_ids) => child_ids, - Err(err) => { - warn!( - "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" - ); - continue; - } - }; - - for child_thread_id in child_ids { - let child_depth = parent_depth + 1; - let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { - true - } else { - let child_session_source = - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: child_depth, - agent_path: None, - agent_nickname: None, - agent_role: None, - }); - match Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - child_thread_id, - child_session_source, - )) - .await - { - Ok(_) => true, - Err(err) => { - warn!("failed to resume descendant thread {child_thread_id}: {err}"); - false - } - } - }; - if child_resumed { - resume_queue.push_back((child_thread_id, child_depth)); - } - } - } - - Ok(resumed_thread_id) - } - - async fn resume_single_agent_from_rollout( - &self, - config: Config, - thread_id: ThreadId, - session_source: SessionSource, - ) -> CodexResult { - let state = self.upgrade()?; - let state_db_ctx = state.state_db(); - let stored_thread = state - .read_stored_thread(ReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await?; - let history = stored_thread - .history - .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? - .items; - let initial_history = InitialHistory::Resumed(ResumedHistory { - conversation_id: thread_id, - history, - rollout_path: stored_thread.rollout_path, - }); - let parent_thread_id = stored_thread.parent_thread_id; - let multi_agent_version = state - .effective_multi_agent_version_for_spawn( - &initial_history, - Some(&session_source), - parent_thread_id, - /*forked_from_thread_id*/ None, - &config, - ) - .await; - let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; - let (session_source, agent_metadata) = match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_role: _, - agent_nickname: _, - }) => { - let (resumed_agent_nickname, resumed_agent_role) = - if let Some(state_db_ctx) = state_db_ctx.as_ref() { - match state_db_ctx.get_thread(thread_id).await { - Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), - Ok(None) | Err(_) => (None, None), - } - } else { - (None, None) - }; - self.prepare_thread_spawn( - &mut reservation, - &config, - parent_thread_id, - depth, - agent_path, - resumed_agent_role, - resumed_agent_nickname, - )? - } - other => (other, AgentMetadata::default()), - }; - let notification_source = session_source.clone(); - let inherited_shell_snapshot = self - .inherited_shell_snapshot_for_source(&state, Some(&session_source)) - .await; - let inherited_exec_policy = self - .inherited_exec_policy_for_source(&state, Some(&session_source), &config) - .await; - - let resumed_thread = state - .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { - config: config.clone(), - initial_history, - agent_control: self.clone(), - session_source, - parent_thread_id, - inherited_shell_snapshot, - inherited_exec_policy, - }) - .await?; - let mut agent_metadata = agent_metadata; - agent_metadata.agent_id = Some(resumed_thread.thread_id); - reservation.commit(agent_metadata.clone()); - // Resumed threads are re-registered in-memory and need the same listener - // attachment path as freshly spawned threads. - state.notify_thread_created(resumed_thread.thread_id); - if multi_agent_version != MultiAgentVersion::V2 { - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| resumed_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - resumed_thread.thread_id, - Some(notification_source.clone()), - child_reference, - agent_metadata.agent_path.clone(), - ); - } - self.persist_thread_spawn_edge_for_source( - resumed_thread.thread.as_ref(), - resumed_thread.thread_id, - Some(¬ification_source), - ) - .await; - - Ok(resumed_thread.thread_id) - } - /// Send rich user input items to an existing agent thread. pub(crate) async fn send_input( &self, @@ -787,86 +188,6 @@ impl AgentControl { result } - /// Submit a shutdown request for a live agent without marking it explicitly closed in - /// persisted spawn-edge state. - pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { - let state = self.upgrade()?; - let result = if let Ok(thread) = state.get_thread(agent_id).await { - thread.codex.session.ensure_rollout_materialized().await; - thread.codex.session.flush_rollout().await?; - let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { - Ok(String::new()) - } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - thread.wait_until_terminated().await; - result - } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - let _ = state.remove_thread(&agent_id).await; - self.state.release_spawned_thread(agent_id); - result - } - - /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the - /// agent and any live descendants reached from the in-memory tree. - pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { - let state = self.upgrade()?; - let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); - match state.get_thread(agent_id).await { - Ok(thread) => { - if let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status( - agent_id, - DirectionalThreadSpawnEdgeStatus::Closed, - ) - .await - { - warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); - } - } - Err(CodexErr::ThreadNotFound(_)) if known_agent => { - if let Some(state_db_ctx) = state.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status( - agent_id, - DirectionalThreadSpawnEdgeStatus::Closed, - ) - .await - { - return Err(CodexErr::Fatal(format!( - "failed to persist stale thread-spawn edge status for {agent_id}: {err}" - ))); - } - } - Err(CodexErr::ThreadNotFound(_)) => {} - Err(err) => { - warn!("failed to inspect agent before close {agent_id}: {err}"); - } - } - match Box::pin(self.shutdown_agent_tree(agent_id)).await { - Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { - Ok(String::new()) - } - result => result, - } - } - - /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. - async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { - let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; - let result = self.shutdown_live_agent(agent_id).await; - for descendant_id in descendant_ids { - match self.shutdown_live_agent(descendant_id).await { - Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} - Err(err) => return Err(err), - } - } - result - } - /// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable. pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus { let Ok(state) = self.upgrade() else { @@ -1140,7 +461,7 @@ impl AgentControl { if let Some(agent_path) = agent_path.as_ref() { reservation.reserve_agent_path(agent_path)?; } - let candidate_names = agent_nickname_candidates(config, agent_role.as_deref()); + let candidate_names = spawn::agent_nickname_candidates(config, agent_role.as_deref()); let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect(); let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference( &candidate_name_refs, diff --git a/codex-rs/core/src/agent/control/legacy.rs b/codex-rs/core/src/agent/control/legacy.rs new file mode 100644 index 0000000000..e85090b905 --- /dev/null +++ b/codex-rs/core/src/agent/control/legacy.rs @@ -0,0 +1,83 @@ +use super::*; + +impl AgentControl { + /// Submit a shutdown request for a live agent without marking it explicitly closed in + /// persisted spawn-edge state. + pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let result = if let Ok(thread) = state.get_thread(agent_id).await { + thread.codex.session.ensure_rollout_materialized().await; + thread.codex.session.flush_rollout().await?; + let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + Ok(String::new()) + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + thread.wait_until_terminated().await; + result + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + result + } + + /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the + /// agent and any live descendants reached from the in-memory tree. + pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); + match state.get_thread(agent_id).await { + Ok(thread) => { + if let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + } + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + if let Some(state_db_ctx) = state.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + return Err(CodexErr::Fatal(format!( + "failed to persist stale thread-spawn edge status for {agent_id}: {err}" + ))); + } + } + Err(CodexErr::ThreadNotFound(_)) => {} + Err(err) => { + warn!("failed to inspect agent before close {agent_id}: {err}"); + } + } + match Box::pin(self.shutdown_agent_tree(agent_id)).await { + Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { + Ok(String::new()) + } + result => result, + } + } + + /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. + pub(crate) async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { + let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; + let result = self.shutdown_live_agent(agent_id).await; + for descendant_id in descendant_ids { + match self.shutdown_live_agent(descendant_id).await { + Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} + Err(err) => return Err(err), + } + } + result + } +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs new file mode 100644 index 0000000000..7cf5d00b4d --- /dev/null +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -0,0 +1,606 @@ +use super::*; + +const AGENT_NAMES: &str = include_str!("../agent_names.txt"); + +struct SpawnAgentThreadInheritance { + shell_snapshot: Option>, + exec_policy: Option>, +} + +fn default_agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + +pub(super) fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + if let Some(candidates) = + resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) + { + return candidates; + } + + default_agent_nickname_list() + .into_iter() + .map(ToOwned::to_owned) + .collect() +} + +fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { + match item { + RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() + { + "system" | "developer" | "user" => true, + "assistant" => *phase == Some(MessagePhase::FinalAnswer), + _ => false, + }, + RolloutItem::ResponseItem( + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other, + ) => false, + // Full-history forks preserve the cached prompt prefix and can keep diffing + // from the parent's durable baseline. Truncated forks drop part of that prompt, + // so they must rebuild context on their first child turn. + RolloutItem::TurnContext(_) => preserve_reference_context_item, + RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, + } +} + +fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "developer" { + return false; + } + let [ContentItem::InputText { text }] = content.as_slice() else { + return false; + }; + + usage_hint_texts + .iter() + .any(|usage_hint_text| usage_hint_text == text) +} + +impl AgentControl { + /// Spawn a new agent thread and submit the initial prompt. + #[cfg(test)] + pub(crate) async fn spawn_agent( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + ) -> CodexResult { + let spawned_agent = Box::pin(self.spawn_agent_internal( + config, + initial_operation, + session_source, + SpawnAgentOptions::default(), + )) + .await?; + Ok(spawned_agent.thread_id) + } + + /// Spawn an agent thread with some metadata. + pub(crate) async fn spawn_agent_with_metadata( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, // TODO(jif) drop with new fork. + ) -> CodexResult { + Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) + .await + } + + async fn spawn_agent_internal( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, + ) -> CodexResult { + let state = self.upgrade()?; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &InitialHistory::New, + session_source.as_ref(), + options.parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let inheritance = SpawnAgentThreadInheritance { + shell_snapshot: self + .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) + .await, + exec_policy: self + .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + .await, + }; + let (session_source, mut agent_metadata) = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role, + .. + })) => { + let (session_source, agent_metadata) = self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + agent_role, + /*preferred_agent_nickname*/ None, + )?; + (Some(session_source), agent_metadata) + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + + // The same `AgentControl` is sent to spawn the thread. + let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { + (Some(session_source), Some(_), inheritance) => { + Box::pin(self.spawn_forked_thread( + &state, + config, + session_source, + &options, + inheritance, + multi_agent_version, + )) + .await? + } + (Some(session_source), None, inheritance) => { + Box::pin(state.spawn_new_thread_with_source( + config.clone(), + self.clone(), + session_source, + options.parent_thread_id, + /*forked_from_thread_id*/ None, + /*thread_source*/ Some(ThreadSource::Subagent), + /*metrics_service_name*/ None, + inheritance.shell_snapshot, + inheritance.exec_policy, + options.environments.clone(), + )) + .await? + } + (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, + }; + agent_metadata.agent_id = Some(new_thread.thread_id); + reservation.commit(agent_metadata.clone()); + + if let Some(SessionSource::SubAgent( + subagent_source @ SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }, + )) = notification_source.as_ref() + { + let client_metadata = match state.get_thread(*parent_thread_id).await { + Ok(parent_thread) => { + parent_thread + .codex + .session + .app_server_client_metadata() + .await + } + Err(error) => { + tracing::warn!( + error = %error, + parent_thread_id = %parent_thread_id, + "skipping subagent thread analytics: failed to load parent thread metadata" + ); + crate::session::session::AppServerClientMetadata { + client_name: None, + client_version: None, + } + } + }; + let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + let parent_thread_id = thread_config.parent_thread_id; + emit_subagent_session_started( + &new_thread + .thread + .codex + .session + .services + .analytics_events_client, + client_metadata, + new_thread.thread.codex.session.session_id(), + new_thread.thread_id, + parent_thread_id, + thread_config, + subagent_source.clone(), + ); + } + + // Notify a new thread has been created. This notification will be processed by clients + // to subscribe or drain this newly created thread. + // TODO(jif) add helper for drain + state.notify_thread_created(new_thread.thread_id); + + self.persist_thread_spawn_edge_for_source( + new_thread.thread.as_ref(), + new_thread.thread_id, + notification_source.as_ref(), + ) + .await; + + self.send_input(new_thread.thread_id, initial_operation) + .await?; + if multi_agent_version != MultiAgentVersion::V2 { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| new_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + new_thread.thread_id, + notification_source, + child_reference, + agent_metadata.agent_path.clone(), + ); + } + + Ok(LiveAgent { + thread_id: new_thread.thread_id, + metadata: agent_metadata, + status: self.get_status(new_thread.thread_id).await, + }) + } + + async fn spawn_forked_thread( + &self, + state: &Arc, + config: Config, + session_source: SessionSource, + options: &SpawnAgentOptions, + inheritance: SpawnAgentThreadInheritance, + multi_agent_version: MultiAgentVersion, + ) -> CodexResult { + let SpawnAgentThreadInheritance { + shell_snapshot: inherited_shell_snapshot, + exec_policy: inherited_exec_policy, + } = inheritance; + if options.fork_parent_spawn_call_id.is_none() { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a parent spawn call id".to_string(), + )); + } + let Some(fork_mode) = options.fork_mode.as_ref() else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a fork mode".to_string(), + )); + }; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &session_source + else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a thread-spawn session source".to_string(), + )); + }; + + let parent_thread_id = *parent_thread_id; + let parent_thread = state.get_thread(parent_thread_id).await.ok(); + if let Some(parent_thread) = parent_thread.as_ref() { + // `record_conversation_items` only queues persistence writes asynchronously. + // Flush before snapshotting store history for a fork. + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await?; + } + + let parent_history = state + .read_stored_thread(ReadThreadParams { + thread_id: parent_thread_id, + include_archived: true, + include_history: true, + }) + .await? + .history + .ok_or_else(|| { + CodexErr::Fatal(format!( + "parent thread history unavailable for fork: {parent_thread_id}" + )) + })?; + + let mut forked_rollout_items = parent_history.items; + if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { + forked_rollout_items = + truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); + } + let multi_agent_v2_usage_hint_texts_to_filter: Vec = + if let Some(parent_thread) = parent_thread.as_ref() { + if multi_agent_version == MultiAgentVersion::V2 { + let parent_config = parent_thread.codex.session.get_config().await; + [ + parent_config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone(), + parent_config + .multi_agent_v2 + .subagent_usage_hint_text + .clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + } + } else if multi_agent_version == MultiAgentVersion::V2 { + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); + forked_rollout_items.retain(|item| { + keep_forked_rollout_item(item, preserve_reference_context_item) + && !matches!( + item, + RolloutItem::ResponseItem(response_item) + if is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + ) + }); + for item in &mut forked_rollout_items { + if let RolloutItem::Compacted(compacted) = item + && let Some(replacement_history) = compacted.replacement_history.as_mut() + { + replacement_history.retain(|response_item| { + !is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + }); + } + } + if preserve_reference_context_item + && multi_agent_version == MultiAgentVersion::V2 + && config.multi_agent_v2.usage_hint_enabled + && let Some(subagent_usage_hint_text) = + config.multi_agent_v2.subagent_usage_hint_text.clone() + && let Some(subagent_usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + subagent_usage_hint_text, + ]) + { + forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); + } + + state + .fork_thread_with_source( + config.clone(), + InitialHistory::Forked(forked_rollout_items), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*parent_thread_id*/ Some(parent_thread_id), + /*forked_from_thread_id*/ Some(parent_thread_id), + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + ) + .await + } + + /// Resume an existing agent thread from a recorded rollout file. + pub(crate) async fn resume_agent_from_rollout( + &self, + config: Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); + let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + thread_id, + session_source, + )) + .await?; + let state = self.upgrade()?; + let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { + return Ok(resumed_thread_id); + }; + let Some(state_db_ctx) = resumed_thread.state_db() else { + return Ok(resumed_thread_id); + }; + + let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); + while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { + let child_ids = match state_db_ctx + .list_thread_spawn_children_with_status( + parent_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + { + Ok(child_ids) => child_ids, + Err(err) => { + warn!( + "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" + ); + continue; + } + }; + + for child_thread_id in child_ids { + let child_depth = parent_depth + 1; + let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { + true + } else { + let child_session_source = + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: child_depth, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + match Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + )) + .await + { + Ok(_) => true, + Err(err) => { + warn!("failed to resume descendant thread {child_thread_id}: {err}"); + false + } + } + }; + if child_resumed { + resume_queue.push_back((child_thread_id, child_depth)); + } + } + } + + Ok(resumed_thread_id) + } + + async fn resume_single_agent_from_rollout( + &self, + config: Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let state = self.upgrade()?; + let state_db_ctx = state.state_db(); + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let history = stored_thread + .history + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + let parent_thread_id = stored_thread.parent_thread_id; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let (session_source, agent_metadata) = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role: _, + agent_nickname: _, + }) => { + let (resumed_agent_nickname, resumed_agent_role) = + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), + Ok(None) | Err(_) => (None, None), + } + } else { + (None, None) + }; + self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + resumed_agent_role, + resumed_agent_nickname, + )? + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + + let resumed_thread = state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config: config.clone(), + initial_history, + agent_control: self.clone(), + session_source, + parent_thread_id, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await?; + let mut agent_metadata = agent_metadata; + agent_metadata.agent_id = Some(resumed_thread.thread_id); + reservation.commit(agent_metadata.clone()); + // Resumed threads are re-registered in-memory and need the same listener + // attachment path as freshly spawned threads. + state.notify_thread_created(resumed_thread.thread_id); + if multi_agent_version != MultiAgentVersion::V2 { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| resumed_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + resumed_thread.thread_id, + Some(notification_source.clone()), + child_reference, + agent_metadata.agent_path.clone(), + ); + } + self.persist_thread_spawn_edge_for_source( + resumed_thread.thread.as_ref(), + resumed_thread.thread_id, + Some(¬ification_source), + ) + .await; + + Ok(resumed_thread.thread_id) + } +} From 5e62c735b2b261e897276b4df544a197c49081d4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 11:43:44 -0300 Subject: [PATCH 062/141] feat(app-server): expose account token usage [1 of 2] (#25344) ## Why Token activity is useful account-level context, but terminal clients need a supported app-server path to fetch it without reaching into ChatGPT backend details directly. The API should also live under the broader account usage umbrella so future usage surfaces can be added without proliferating user-facing concepts. ## What Changed - Add `codex-backend-client` support for the ChatGPT profile token-usage payload. - Add the v2 `account/usage/read` app-server RPC. - Map lifetime usage, peak daily usage, streak, longest task duration, and daily buckets into app-server protocol types. - Gate the request on Codex-backend auth, which supports ChatGPT auth tokens and AgentIdentity. - Regenerate the app-server JSON and TypeScript schema fixtures. ## Token Count Source `account/usage/read` returns the token-usage aggregate supplied by the ChatGPT profile backend. App-server maps that backend-owned aggregate into protocol fields; it does not recompute cached-token treatment, usage multipliers, or raw input/output totals locally. ## Stack 1. feat(app-server): expose account token usage [1 of 2] (this PR) 2. [#25345](https://github.com/openai/codex/pull/25345) feat(tui): add token activity command [2 of 2] ## How to Test 1. Start an app-server client from this branch while authenticated with ChatGPT or AgentIdentity. 2. Call `account/usage/read`. 3. Confirm the response includes `summary` and `dailyUsageBuckets`. 4. Also verify a session without Codex-backend auth receives the existing auth error path. Targeted tests: - `just test -p codex-backend-client -p codex-app-server-protocol -p codex-app-server` - `just write-app-server-schema` --- .../schema/json/ClientRequest.json | 23 ++++ .../codex_app_server_protocol.schemas.json | 101 ++++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 101 ++++++++++++++++++ .../json/v2/GetAccountTokenUsageResponse.json | 80 ++++++++++++++ .../schema/typescript/ClientRequest.ts | 2 +- .../v2/AccountTokenUsageDailyBucket.ts | 5 + .../typescript/v2/AccountTokenUsageSummary.ts | 5 + .../v2/GetAccountTokenUsageResponse.ts | 7 ++ .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 +++++ .../src/protocol/v2/account.rs | 27 +++++ codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 4 + .../request_processors/account_processor.rs | 100 +++++++++++++++++ codex-rs/backend-client/src/client.rs | 62 +++++++---- codex-rs/backend-client/src/lib.rs | 3 + codex-rs/backend-client/src/types.rs | 21 ++++ 18 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c6e1aa3302..6d9be4ec02 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5969,6 +5969,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 10bde05597..a5578e0072 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1833,6 +1833,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -5759,6 +5782,62 @@ "title": "AccountRateLimitsUpdatedNotification", "type": "object" }, + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "AccountUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9626,6 +9705,28 @@ "title": "GetAccountResponse", "type": "object" }, + "GetAccountTokenUsageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/v2/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/v2/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e4c91e7586..36b95b4f64 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -104,6 +104,62 @@ "title": "AccountRateLimitsUpdatedNotification", "type": "object" }, + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "AccountUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2679,6 +2735,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -6079,6 +6158,28 @@ "title": "GetAccountResponse", "type": "object" }, + "GetAccountTokenUsageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json new file mode 100644 index 0000000000..557a297ce1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e19ac2dfde..c91792c726 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -86,4 +86,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts new file mode 100644 index 0000000000..a92c6c00c4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AccountTokenUsageDailyBucket = { startDate: string, tokens: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts new file mode 100644 index 0000000000..6f87acdaff --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AccountTokenUsageSummary = { lifetimeTokens: bigint | null, peakDailyTokens: bigint | null, longestRunningTurnSec: bigint | null, currentStreakDays: bigint | null, longestStreakDays: bigint | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts new file mode 100644 index 0000000000..175c915272 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket"; +import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary"; + +export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index bb19c92bdf..727c049d10 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -3,6 +3,8 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; +export type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket"; +export type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; @@ -141,6 +143,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; +export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 47410551cf..a624387e4d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -949,6 +949,12 @@ client_request_definitions! { response: v2::GetAccountRateLimitsResponse, }, + GetAccountTokenUsage => "account/usage/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::GetAccountTokenUsageResponse, + }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { params: v2::SendAddCreditsNudgeEmailParams, serialization: global("account-auth"), @@ -2372,6 +2378,24 @@ mod tests { Ok(()) } + #[test] + fn serialize_get_account_token_usage() -> Result<()> { + let request = ClientRequest::GetAccountTokenUsage { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/usage/read"); + assert_eq!( + json!({ + "method": "account/usage/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 83f1bc02b8..58fc93cc76 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -258,6 +258,33 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits_by_limit_id: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountTokenUsageResponse { + pub summary: AccountTokenUsageSummary, + pub daily_usage_buckets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountTokenUsageSummary { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountTokenUsageDailyBucket { + pub start_date: String, + pub tokens: i64, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d112eeff9d..b7fc6b82a3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1779,6 +1779,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). +- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6c578ce8e5..07bc06218e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1294,6 +1294,9 @@ impl MessageProcessor { ClientRequest::GetAccountRateLimits { .. } => { self.account_processor.get_account_rate_limits().await } + ClientRequest::GetAccountTokenUsage { .. } => { + self.account_processor.get_account_token_usage().await + } ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { self.account_processor .send_add_credits_nudge_email(params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 0e94528a7a..00468b0a99 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -22,6 +22,8 @@ use codex_analytics::InputError; use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountTokenUsageDailyBucket; +use codex_app_server_protocol::AccountTokenUsageSummary; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; @@ -64,6 +66,7 @@ use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; @@ -266,6 +269,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; +use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; use codex_config::CloudConfigBundleLoadError; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 8ebfc037e1..21160d6810 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -2,6 +2,7 @@ use super::*; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); // The override is intentionally available only in debug builds, matching the login path below. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; @@ -131,6 +132,14 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn get_account_token_usage( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_account_token_usage_response() + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn send_add_credits_nudge_email( &self, params: SendAddCreditsNudgeEmailParams, @@ -848,6 +857,55 @@ impl AccountRequestProcessor { ) } + async fn get_account_token_usage_response( + &self, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read token usage", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read token usage", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let profile = tokio::time::timeout( + ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT, + client.get_token_usage_profile(), + ) + .await + .map_err(|_| internal_error("token usage profile fetch timed out"))? + .map_err(|err| internal_error(format!("failed to fetch token usage profile: {err}")))?; + Ok(Self::account_token_usage_response(profile)) + } + + fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { + let stats = profile.stats; + GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: stats.lifetime_tokens, + peak_daily_tokens: stats.peak_daily_tokens, + longest_running_turn_sec: stats.longest_running_turn_sec, + current_streak_days: stats.current_streak_days, + longest_streak_days: stats.longest_streak_days, + }, + daily_usage_buckets: stats.daily_usage_buckets.map(|buckets| { + buckets + .into_iter() + .map(|bucket| AccountTokenUsageDailyBucket { + start_date: bucket.start_date, + tokens: bucket.tokens, + }) + .collect() + }), + } + } + async fn send_add_credits_nudge_email_response( &self, params: SendAddCreditsNudgeEmailParams, @@ -952,3 +1010,45 @@ impl AccountRequestProcessor { Ok((primary, rate_limits_by_limit_id)) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_backend_client::TokenUsageProfileDailyBucket; + use codex_backend_client::TokenUsageProfileStats; + use pretty_assertions::assert_eq; + + #[test] + fn account_token_usage_response_maps_profile_stats_and_daily_buckets() { + let response = AccountRequestProcessor::account_token_usage_response(TokenUsageProfile { + stats: TokenUsageProfileStats { + lifetime_tokens: Some(123), + peak_daily_tokens: Some(45), + longest_running_turn_sec: Some(67), + current_streak_days: Some(8), + longest_streak_days: Some(9), + daily_usage_buckets: Some(vec![TokenUsageProfileDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }]), + }, + }); + + assert_eq!( + response, + GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: Some(123), + peak_daily_tokens: Some(45), + longest_running_turn_sec: Some(67), + current_streak_days: Some(8), + longest_streak_days: Some(9), + }, + daily_usage_buckets: Some(vec![AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }]), + } + ); + } +} diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 24d2776590..52275eb4be 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -4,6 +4,7 @@ use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; +use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; @@ -313,6 +314,20 @@ impl Client { self.decode_json(&url, &ct, &body) } + pub async fn get_token_usage_profile(&self) -> Result { + let url = self.token_usage_profile_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + + fn token_usage_profile_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url), + } + } + pub async fn send_add_credits_nudge_email( &self, credit_type: AddCreditsNudgeCreditType, @@ -883,29 +898,13 @@ mod tests { #[test] fn add_credits_nudge_email_uses_expected_paths_and_bodies() { - let codex_client = Client { - base_url: "https://example.test".to_string(), - http: reqwest::Client::new(), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), - user_agent: None, - chatgpt_account_id: None, - chatgpt_account_is_fedramp: false, - path_style: PathStyle::CodexApi, - }; + let codex_client = test_client("https://example.test", PathStyle::CodexApi); assert_eq!( codex_client.send_add_credits_nudge_email_url(), "https://example.test/api/codex/accounts/send_add_credits_nudge_email" ); - let chatgpt_client = Client { - base_url: "https://chatgpt.com/backend-api".to_string(), - http: reqwest::Client::new(), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), - user_agent: None, - chatgpt_account_id: None, - chatgpt_account_is_fedramp: false, - path_style: PathStyle::ChatGptApi, - }; + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); assert_eq!( chatgpt_client.send_add_credits_nudge_email_url(), "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" @@ -926,4 +925,31 @@ mod tests { serde_json::json!({ "credit_type": "usage_limit" }) ); } + + #[test] + fn token_usage_profile_uses_expected_paths() { + let codex_client = test_client("https://example.test", PathStyle::CodexApi); + assert_eq!( + codex_client.token_usage_profile_url(), + "https://example.test/api/codex/profiles/me" + ); + + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); + assert_eq!( + chatgpt_client.token_usage_profile_url(), + "https://chatgpt.com/backend-api/wham/profiles/me" + ); + } + + fn test_client(base_url: &str, path_style: PathStyle) -> Client { + Client { + base_url: base_url.to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style, + } + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index dfd7e816f6..e50fd8db40 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -14,4 +14,7 @@ pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; pub use types::TaskListItem; +pub use types::TokenUsageProfile; +pub use types::TokenUsageProfileDailyBucket; +pub use types::TokenUsageProfileStats; pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 06989241fa..3ccacbc8c9 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -409,6 +409,27 @@ pub struct TurnAttemptsSiblingTurnsResponse { pub sibling_turns: Vec>, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfile { + pub stats: TokenUsageProfileStats, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfileStats { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, + pub daily_usage_buckets: Option>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfileDailyBucket { + pub start_date: String, + pub tokens: i64, +} + #[cfg(test)] mod tests { use super::*; From a14a73b54af26af4a5428ba2eb482a1ce54f8974 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 5 Jun 2026 08:00:46 -0700 Subject: [PATCH 063/141] [codex] Fix long proxy socket paths (#26553) ## Summary - avoid generating host proxy bridge Unix socket paths that exceed Linux's `sockaddr_un.sun_path` limit - fall back from a long `$CODEX_HOME/tmp` path to the system temp directory, then `/tmp` - add focused unit coverage for short and overlong parent paths ## Root cause With a sufficiently long `CODEX_HOME`, the generated `proxy-route-*.sock` path exceeds Linux's 107-byte pathname limit. The host bridge child exits before writing its readiness byte, so the parent reports the indirect error `failed to prepare host proxy routing bridge: failed to fill whole buffer`. ## Validation - reproduced the original error with a long `CODEX_HOME` using `codex-cli 0.138.0-alpha.4` - `cargo clippy -p codex-linux-sandbox --all-targets` - `just fix -p codex-linux-sandbox` - `just fmt` The Linux-only unit test could not execute locally: the arm64 Docker build was repeatedly OOM-killed by `rustc` while compiling an unrelated `codex-app-server-protocol` dependency, before reaching the test. --------- Co-authored-by: Codex --- codex-rs/linux-sandbox/src/proxy_routing.rs | 45 +++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index d28b864666..f171d91745 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -14,6 +14,7 @@ use std::net::SocketAddr; use std::net::TcpListener; use std::net::TcpStream; use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::DirBuilderExt; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; @@ -43,6 +44,8 @@ const PROXY_ENV_KEYS: &[&str] = &[ const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; const HOST_BRIDGE_READY: u8 = 1; const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; +// Linux sockaddr_un.sun_path allows 108 bytes, including the trailing NUL. +const UNIX_SOCKET_PATH_MAX_BYTES: usize = 107; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct ProxyRouteSpec { @@ -278,8 +281,9 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option { fn create_proxy_socket_dir() -> io::Result { let temp_dir = proxy_socket_parent_dir(); let pid = std::process::id(); + let uid = unsafe { libc::geteuid() }; for attempt in 0..128 { - let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}")); + let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{uid}-{attempt}")); // The bridge UDS paths live under a shared temp root, so the per-run // directory should not be traversable by other processes. let mut dir_builder = DirBuilder::new(); @@ -302,11 +306,29 @@ fn create_proxy_socket_dir() -> io::Result { fn proxy_socket_parent_dir() -> PathBuf { if let Some(codex_home) = std::env::var_os("CODEX_HOME") { let candidate = PathBuf::from(codex_home).join("tmp"); - if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() { + if proxy_socket_paths_fit(candidate.as_path()) + && ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() + { return candidate; } } - std::env::temp_dir() + let temp_dir = std::env::temp_dir(); + if proxy_socket_paths_fit(temp_dir.as_path()) { + temp_dir + } else { + PathBuf::from("/tmp") + } +} + +fn proxy_socket_paths_fit(parent: &Path) -> bool { + let socket_path = parent + .join(format!( + "{PROXY_SOCKET_DIR_PREFIX}{}-{}-127", + u32::MAX, + libc::uid_t::MAX + )) + .join(format!("proxy-route-{}.sock", usize::MAX)); + socket_path.as_os_str().as_bytes().len() <= UNIX_SOCKET_PATH_MAX_BYTES } fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> { @@ -661,6 +683,7 @@ mod tests { use super::parse_loopback_proxy_endpoint; use super::parse_proxy_socket_dir_owner_pid; use super::plan_proxy_routes; + use super::proxy_socket_paths_fit; use super::rewrite_proxy_env_value; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -735,6 +758,18 @@ mod tests { assert_eq!(default_proxy_port("socks5h"), 1080); } + #[test] + fn proxy_socket_paths_enforce_linux_path_limit() { + assert_eq!( + proxy_socket_paths_fit(PathBuf::from("/tmp").as_path()), + true + ); + assert_eq!( + proxy_socket_paths_fit(PathBuf::from(format!("/tmp/{}", "a".repeat(96))).as_path()), + false + ); + } + #[test] fn cleanup_proxy_socket_dir_removes_bridge_artifacts() { let root = tempfile::tempdir().expect("tempdir should create"); @@ -770,6 +805,10 @@ mod tests { parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"), Some(1234) ); + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-1000-0"), + Some(1234) + ); assert_eq!( parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"), None From 3acd71fedb1284e0f27f400e2d354a942b273421 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:32:07 -0700 Subject: [PATCH 064/141] Surface TUI config write error causes (#26537) ## Summary TUI config writes currently wrap app-server failures with local context like `config/batchWrite failed in TUI`, but several user-visible paths only render the outer error. That hides the actionable app-server message, such as validation constraints or read-only `CODEX_HOME` failures, leaving users with a dead-end diagnostic. This change adds a small formatter next to the TUI config write helpers that renders the error source chain, then uses it for model persistence, feature persistence, project trust, status line writes, hook trust, and hook enablement. Fixes #26077 --- codex-rs/tui/src/app/background_requests.rs | 12 ++++-- codex-rs/tui/src/app/config_persistence.rs | 5 ++- codex-rs/tui/src/app/event_dispatch.rs | 11 +++-- ...onfig_error_wraps_in_history_snapshot.snap | 7 ++++ codex-rs/tui/src/chatwidget/tests.rs | 2 + .../chatwidget/tests/config_errors_tests.rs | 25 ++++++++++++ codex-rs/tui/src/config_update.rs | 31 ++++---------- codex-rs/tui/src/config_update_tests.rs | 40 +++++++++++++++++++ .../tui/src/onboarding/onboarding_screen.rs | 4 +- ...sts__renders_snapshot_for_trust_error.snap | 19 +++++++++ .../tui/src/onboarding/trust_directory.rs | 38 +++++++++++++----- ...w__tests__startup_hooks_review_prompt.snap | 18 ++++----- ..._hooks_review_prompt_with_trust_error.snap | 24 ++++++----- codex-rs/tui/src/startup_hooks_review.rs | 17 +++++--- 14 files changed, 185 insertions(+), 68 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs create mode 100644 codex-rs/tui/src/config_update_tests.rs create mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index d90f511d6f..456676f796 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -7,6 +7,7 @@ use super::plugin_mentions::fetch_plugin_mentions; use super::*; use crate::app_event::ConnectorsSnapshot; +use crate::config_update::format_config_error; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::MarketplaceAddParams; @@ -357,7 +358,12 @@ impl App { let result = write_hook_enabled(request_handle, key, enabled) .await .map(|_| ()) - .map_err(|err| format!("Failed to update hook config: {err}")); + .map_err(|err| { + format!( + "Failed to update hook config: {}", + format_config_error(&err) + ) + }); app_event_tx.send(AppEvent::HookEnabledSet { key: key_for_event, enabled, @@ -378,7 +384,7 @@ impl App { let result = write_hook_trust(request_handle, key, current_hash) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hook: {err}")); + .map_err(|err| format!("Failed to trust hook: {}", format_config_error(&err))); app_event_tx.send(AppEvent::HookTrusted { result }); }); } @@ -394,7 +400,7 @@ impl App { let result = write_hook_trusts(request_handle, updates) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hooks: {err}")); + .map_err(|err| format!("Failed to trust hooks: {}", format_config_error(&err))); app_event_tx.send(AppEvent::HookTrusted { result }); }); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 946223ec3e..319830b82a 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -482,9 +482,10 @@ impl App { { Ok(response) => response, Err(err) => { - tracing::error!(error = %err, "failed to persist feature flags"); + let error = crate::config_update::format_config_error(&err); + tracing::error!(error = %error, "failed to persist feature flags"); self.chat_widget - .add_error_message(format!("Failed to update experimental features: {err}")); + .add_error_message(format!("Failed to update experimental features: {error}")); return; } }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 58f090e4c7..548aa54427 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -5,6 +5,7 @@ use super::resize_reflow::trailing_run_start; use super::*; +use crate::config_update::format_config_error; #[cfg(target_os = "windows")] use codex_config::types::WindowsSandboxModeToml; @@ -1321,12 +1322,13 @@ impl App { self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { + let error = format_config_error(&err); tracing::error!( - error = %err, + error = %error, "failed to persist model selection" ); self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); + .add_error_message(format!("Failed to save default model: {error}")); } } } @@ -1945,9 +1947,10 @@ impl App { self.chat_widget.setup_status_line(items, use_theme_colors); } Err(err) => { - tracing::error!(error = %err, "failed to persist status line settings; keeping previous selection"); + let error = format_config_error(&err); + tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection"); self.chat_widget.add_error_message(format!( - "Failed to save status line settings: {err}" + "Failed to save status line settings: {error}" )); } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap new file mode 100644 index 0000000000..a3a7e21365 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/config_errors_tests.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) +--- +■ Failed to save default model: config/batchWrite failed +in TUI: Invalid configuration: features.fast_mode=true +is not supported; allowed set [fast_mode=false] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2da524910a..ba89379e73 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -219,6 +219,8 @@ macro_rules! assert_chatwidget_snapshot { mod app_server; mod approval_requests; mod composer_submission; +#[path = "tests/config_errors_tests.rs"] +mod config_errors; mod exec_flow; mod goal_menu; mod goal_validation; diff --git a/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs b/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs new file mode 100644 index 0000000000..645c01f0f4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[tokio::test] +async fn chained_config_error_wraps_in_history_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.add_error_message( + "Failed to save default model: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" + .to_string(), + ); + + let width = 56; + let height = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(ratatui::layout::Rect::new(0, 0, width, height)); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("insert history lines"); + } + + assert_chatwidget_snapshot!( + "chained_config_error_wraps_in_history_snapshot", + normalize_snapshot_paths(term.backend().vt100().screen().contents()) + ); +} diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index a882b21231..a71f34b52b 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -23,6 +23,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use serde_json::Value as JsonValue; +use std::fmt::Display; use std::path::Path; use uuid::Uuid; @@ -43,6 +44,10 @@ pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { format!("apps.{app_id}.{key_path}") } +pub(crate) fn format_config_error(err: &impl Display) -> String { + format!("{err:#}") +} + fn trusted_project_edit(project_path: &Path) -> ConfigEdit { let project_key = project_trust_key(project_path) .replace('\\', "\\\\") @@ -203,27 +208,5 @@ pub(crate) async fn write_skill_enabled( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn app_scoped_key_path_quotes_dotted_app_ids() { - assert_eq!( - app_scoped_key_path("plugin.linear", "enabled"), - "apps.\"plugin.linear\".enabled" - ); - } - - #[test] - fn trusted_project_edit_targets_project_trust_level() { - assert_eq!( - trusted_project_edit(Path::new("/workspace/team.project")), - ConfigEdit { - key_path: "projects.\"/workspace/team.project\".trust_level".to_string(), - value: serde_json::json!("trusted"), - merge_strategy: MergeStrategy::Replace, - } - ); - } -} +#[path = "config_update_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs new file mode 100644 index 0000000000..ee7508862c --- /dev/null +++ b/codex-rs/tui/src/config_update_tests.rs @@ -0,0 +1,40 @@ +use super::*; +use color_eyre::eyre::WrapErr; +use pretty_assertions::assert_eq; +use std::path::Path; + +#[test] +fn app_scoped_key_path_quotes_dotted_app_ids() { + assert_eq!( + app_scoped_key_path("plugin.linear", "enabled"), + "apps.\"plugin.linear\".enabled" + ); +} + +#[test] +fn trusted_project_edit_targets_project_trust_level() { + assert_eq!( + trusted_project_edit(Path::new("/workspace/team.project")), + ConfigEdit { + key_path: "projects.\"/workspace/team.project\".trust_level".to_string(), + value: serde_json::json!("trusted"), + merge_strategy: MergeStrategy::Replace, + } + ); +} + +#[test] +fn format_config_error_preserves_server_validation_message() { + let err = Err::<(), _>(color_eyre::eyre::eyre!( + "config/batchWrite failed: Invalid configuration: features.fast_mode=true violates \ + managed requirements; allowed set [fast_mode=false]" + )) + .wrap_err("config/batchWrite failed in TUI") + .unwrap_err(); + + assert_eq!( + format_config_error(&err), + "config/batchWrite failed in TUI: config/batchWrite failed: Invalid configuration: \ + features.fast_mode=true violates managed requirements; allowed set [fast_mode=false]" + ); +} diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 5291934a31..e4c4b346d5 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -32,6 +32,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; +use crate::config_update::format_config_error; use crate::config_update::write_trusted_project; use crate::key_hint::KeyBindingListExt; use crate::legacy_core::config::Config; @@ -605,8 +606,9 @@ async fn persist_selected_trust( match result { Ok(()) => true, Err(error) => { + let error = format_config_error(&error); tracing::error!( - "failed to persist trusted project state for {}: {error:?}", + "failed to persist trusted project state for {}: {error}", trust_target.display() ); if let Step::TrustDirectory(widget) = &mut onboarding_screen.steps[trust_step_index] { diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap new file mode 100644 index 0000000000..6f7a495d1a --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/onboarding/trust_directory.rs +expression: terminal.backend() +--- +> You are in /workspace/project + + Do you trust the contents of this directory? Working with untrusted + contents comes with higher risk of prompt injection. Trusting the + directory allows project-local config, hooks, and exec policies to + load. + +› 1. Yes, continue + 2. No, quit + + Failed to set trust for /workspace/project: config/batchWrite failed + in TUI: Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + + Press enter to continue diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 36d13e5a5a..8a98cbca6b 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -191,6 +191,18 @@ mod tests { use ratatui::Terminal; use std::path::PathBuf; + fn widget(error: Option) -> TrustDirectoryWidget { + TrustDirectoryWidget { + cwd: PathBuf::from("/workspace/project"), + trust_target: PathBuf::from("/workspace/project"), + show_windows_create_sandbox_hint: false, + should_quit: false, + selection: None, + highlighted: TrustDirectorySelection::Trust, + error, + } + } + #[test] fn release_event_does_not_change_selection() { let mut widget = TrustDirectoryWidget { @@ -217,15 +229,7 @@ mod tests { #[test] fn renders_snapshot_for_git_repo() { - let widget = TrustDirectoryWidget { - cwd: PathBuf::from("/workspace/project"), - trust_target: PathBuf::from("/workspace/project"), - show_windows_create_sandbox_hint: false, - should_quit: false, - selection: None, - highlighted: TrustDirectorySelection::Trust, - error: None, - }; + let widget = widget(/*error*/ None); let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 14)).expect("terminal"); @@ -235,4 +239,20 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + + #[test] + fn renders_snapshot_for_trust_error() { + let widget = widget(Some( + "Failed to set trust for /workspace/project: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" + .to_string(), + )); + + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal"); + terminal + .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) + .expect("draw"); + + insta::assert_snapshot!(terminal.backend()); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap index 038534e98a..49106a3f5b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap @@ -2,13 +2,13 @@ source: tui/src/startup_hooks_review.rs expression: "render_lines(&view, 80)" --- - - Hooks need review - 2 hooks are new or changed. - Hooks can run outside the sandbox after you trust them. - -› 1. Review hooks - 2. Trust all and continue - 3. Continue without trusting (hooks won't run) - + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap index 340c0d233c..574c31b497 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap @@ -1,15 +1,17 @@ --- source: tui/src/startup_hooks_review.rs -expression: "render_lines(&view, 80)" +expression: "render_lines(&view, 62)" --- - - Hooks need review - 2 hooks are new or changed. - Hooks can run outside the sandbox after you trust them. - Failed to trust hooks: disk full - -› 1. Review hooks - 2. Trust all and continue - 3. Continue without trusting (hooks won't run) - + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + Failed to trust hooks: config/batchWrite failed in TUI: + Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 559c796aa0..67d6133a10 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -5,7 +5,9 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::StreamExt; @@ -17,6 +19,7 @@ use crate::bottom_pane::ListSelectionView; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; +use crate::config_update::format_config_error; use crate::hooks_rpc::HookTrustUpdate; use crate::hooks_rpc::fetch_hooks_list; use crate::hooks_rpc::hook_needs_review; @@ -130,7 +133,9 @@ async fn run_startup_hooks_review_app( ) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hooks: {err}")); + .map_err(|err| { + format!("Failed to trust hooks: {}", format_config_error(&err)) + }); match result { Ok(()) => return Ok(StartupHooksReviewOutcome::Continue), Err(err) => { @@ -199,7 +204,7 @@ fn selection_view_params( "Hooks can run outside the sandbox after you trust them.".dim(), )); if let Some(error) = trust_all_error { - header.push(Line::from(error.to_string()).red()); + header.push(Paragraph::new(Line::from(error.to_string()).red()).wrap(Wrap { trim: false })); } else if trusting_all { header.push(Line::from("Trusting hooks...".dim())); } @@ -333,7 +338,7 @@ mod tests { } }) .collect::(); - format!("{rendered:width$}", width = area.width as usize) + rendered.trim_end().to_string() }) .collect::>() .join("\n") @@ -373,7 +378,9 @@ mod tests { let keymap = RuntimeKeymap::defaults(); let view = selection_view( &entry(), - Some("Failed to trust hooks: disk full"), + Some( + "Failed to trust hooks: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]", + ), /*trusting_all*/ false, AppEventSender::new(tx_raw), &keymap, @@ -381,7 +388,7 @@ mod tests { assert_snapshot!( "startup_hooks_review_prompt_with_trust_error", - render_lines(&view, /*width*/ 80) + render_lines(&view, /*width*/ 62) ); } } From e781816eadf1986c6ccb382b34a8d5bb4c23fb70 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:32:42 -0700 Subject: [PATCH 065/141] Open Windows app workspaces via deep link (#26500) ## Summary Fixes #26423. On Windows, `codex app PATH` detected Codex Desktop and launched the app shell target, then only printed a manual instruction to open the workspace. The Desktop app already supports `codex://threads/new?path=...`, so the CLI can open the requested workspace directly. This updates the Windows launcher to normalize the workspace path, encode it into a `codex://threads/new` deep link, and open that URL when Codex Desktop is installed. The installer fallback still opens the Windows installer and prints the workspace path for after installation. --- codex-rs/cli/src/desktop_app/windows.rs | 64 ++++++++++++------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/codex-rs/cli/src/desktop_app/windows.rs b/codex-rs/cli/src/desktop_app/windows.rs index 932ca00cf2..717c54dda4 100644 --- a/codex-rs/cli/src/desktop_app/windows.rs +++ b/codex-rs/cli/src/desktop_app/windows.rs @@ -11,13 +11,11 @@ pub async fn run_windows_app_open_or_install( workspace: PathBuf, download_url_override: Option, ) -> anyhow::Result<()> { - if let Some(app_id) = find_codex_app_id().await? { - eprintln!("Opening Codex Desktop..."); - open_installed_codex_app(&app_id).await?; - eprintln!( - "In Codex Desktop, open workspace {workspace}.", - workspace = display_workspace_path(&workspace) - ); + let workspace_path = workspace.display().to_string(); + let display_workspace = display_workspace_path(&workspace); + if codex_app_is_installed().await? { + eprintln!("Opening Codex Desktop workspace {display_workspace}..."); + open_url(&codex_new_thread_url(&workspace_path)).await?; return Ok(()); } @@ -28,14 +26,11 @@ pub async fn run_windows_app_open_or_install( if open_url(download_url).await.is_err() && download_url_override.is_none() { open_url(CODEX_MICROSOFT_STORE_WEB_URL).await?; } - eprintln!( - "After installing Codex Desktop, open workspace {workspace}.", - workspace = display_workspace_path(&workspace) - ); + eprintln!("After installing Codex Desktop, open workspace {display_workspace}."); Ok(()) } -async fn find_codex_app_id() -> anyhow::Result> { +async fn codex_app_is_installed() -> anyhow::Result { let output = Command::new("powershell.exe") .arg("-NoProfile") .arg("-Command") @@ -45,20 +40,10 @@ async fn find_codex_app_id() -> anyhow::Result> { .context("failed to invoke `powershell.exe`")?; if !output.status.success() { - return Ok(None); + return Ok(false); } - let app_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if app_id.is_empty() { - Ok(None) - } else { - Ok(Some(app_id)) - } -} - -async fn open_installed_codex_app(app_id: &str) -> anyhow::Result<()> { - let target = format!("shell:AppsFolder\\{app_id}"); - open_shell_target(&target).await + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) } async fn open_url(url: &str) -> anyhow::Result<()> { @@ -78,15 +63,11 @@ async fn open_url(url: &str) -> anyhow::Result<()> { } } -async fn open_shell_target(target: &str) -> anyhow::Result<()> { - // Explorer can successfully hand off shell targets and still return exit code 1. - let _status = Command::new("explorer.exe") - .arg(target) - .status() - .await - .with_context(|| format!("failed to open {target}"))?; - - Ok(()) +fn codex_new_thread_url(workspace: &str) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("path", workspace); + let query = serializer.finish(); + format!("codex://threads/new?{query}") } fn display_workspace_path(workspace: &Path) -> String { @@ -102,6 +83,7 @@ fn display_workspace_path(workspace: &Path) -> String { #[cfg(test)] mod tests { + use super::codex_new_thread_url; use super::display_workspace_path; use pretty_assertions::assert_eq; use std::path::Path; @@ -129,4 +111,20 @@ mod tests { r"C:\Users\fcoury\code\codex" ); } + + #[test] + fn codex_new_thread_url_encodes_windows_workspace_path() { + assert_eq!( + codex_new_thread_url(r"C:\Users\akuma\repos\koba"), + r"codex://threads/new?path=C%3A%5CUsers%5Cakuma%5Crepos%5Ckoba" + ); + } + + #[test] + fn codex_new_thread_url_preserves_verbatim_workspace_path() { + assert_eq!( + codex_new_thread_url(r"\\?\C:\Users\akuma\repos\koba"), + r"codex://threads/new?path=%5C%5C%3F%5CC%3A%5CUsers%5Cakuma%5Crepos%5Ckoba" + ); + } } From fb0993dd3b68315408eaa109924c63f434e85382 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:32:53 -0700 Subject: [PATCH 066/141] Fix `/goal` usage text for control commands (#26551) ## Why The TUI's `/goal` usage text only advertised the objective form even though `/goal clear`, `/goal edit`, `/goal pause`, and `/goal resume` are implemented. This made the lifecycle controls difficult to discover and allowed the duplicated help text to drift from actual behavior. Fixes #25530. ## What changed - Show the complete `/goal [|clear|edit|pause|resume]` syntax in usage messages. - Share one usage string across slash-command dispatch and goal-related app messages. - Add inline snapshot coverage for the control-command usage path. --- codex-rs/tui/src/app/thread_goal_actions.rs | 5 +++-- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 2 +- .../tui/src/chatwidget/tests/slash_commands.rs | 15 +++++++++++++++ codex-rs/tui/src/goal_display.rs | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index 5911801755..b1a1bd3678 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -6,6 +6,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::goal_display::GOAL_USAGE; use crate::goal_display::goal_status_label; use crate::goal_display::goal_usage_summary; use codex_app_server_protocol::ThreadGoal; @@ -39,7 +40,7 @@ impl App { let Some(goal) = response.goal else { self.chat_widget.add_info_message( - "Usage: /goal ".to_string(), + GOAL_USAGE.to_string(), Some("No goal is currently set.".to_string()), ); return; @@ -280,7 +281,7 @@ impl App { self.chat_widget .add_error_message("No goal is currently set.".to_string()); self.chat_widget.add_info_message( - "Usage: /goal ".to_string(), + GOAL_USAGE.to_string(), Some("Create a goal before editing it.".to_string()), ); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 08c6db56dc..48caf8f54d 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -13,6 +13,7 @@ use crate::bottom_pane::slash_commands::BuiltinCommandFlags; use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::bottom_pane::slash_commands::SlashCommandItem; use crate::bottom_pane::slash_commands::find_slash_command; +use crate::goal_display::GOAL_USAGE; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -32,7 +33,6 @@ struct PreparedSlashCommandArgs { const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; -const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 099ae1d76d..9c2802fb60 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -784,6 +784,21 @@ async fn goal_control_slash_commands_emit_goal_events() { } } +#[tokio::test] +async fn goal_control_slash_command_without_thread_shows_full_usage() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + + submit_composer_text(&mut chat, "/goal pause"); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected goal usage message"); + insta::assert_snapshot!( + lines_to_single_string(&cells[0]), + @"• Usage: /goal [|clear|edit|pause|resume] The session must start before you can change a goal." + ); +} + #[tokio::test] async fn goal_edit_slash_command_opens_goal_editor() { for thread_id in [Some(ThreadId::new()), None] { diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs index 961b87f16e..210bac1076 100644 --- a/codex-rs/tui/src/goal_display.rs +++ b/codex-rs/tui/src/goal_display.rs @@ -2,6 +2,8 @@ use crate::status::format_tokens_compact; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalStatus; +pub(crate) const GOAL_USAGE: &str = "Usage: /goal [|clear|edit|pause|resume]"; + pub(crate) fn format_goal_elapsed_seconds(seconds: i64) -> String { let seconds = seconds.max(0) as u64; if seconds < 60 { From e5af672d730aa2a11c5e0ca5bc933f8c5195317a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 08:34:34 -0700 Subject: [PATCH 067/141] Render code comment directives in TUI replay (#26554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resumed Codex App or VS Code review sessions can contain `::code-comment` directives that the TUI previously displayed verbatim because only rich clients interpret them. This change rewrites valid line-start directives into readable Markdown during assistant-message parsing, using the session working directory for relative file paths. The fallback is applied consistently to live messages, replayed transcripts, and resume previews while preserving malformed directives and existing `::git-*` parsing. ## Before The TUI exposed the raw client directive: ```text ::code-comment{title="Fix body= parsing" body="Keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"} ``` ## After The same directive is rendered as readable review feedback: ```text - [P2] Fix body= parsing — src/app.ts:10-12 Keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. ``` Fixes #25658 --- codex-rs/tui/src/chatwidget/streaming.rs | 5 +- codex-rs/tui/src/chatwidget/turn_runtime.rs | 6 +- codex-rs/tui/src/git_action_directives.rs | 151 +++++++++++++++++- codex-rs/tui/src/resume_picker.rs | 3 +- codex-rs/tui/src/resume_picker/transcript.rs | 2 +- ...ests__code_comment_directive_fallback.snap | 11 ++ 6 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 3dd9f26ee9..848b0c2ef3 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -38,7 +38,8 @@ impl ChatWidget { // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell // that can re-render from source on resize. if let Some(source) = source { - let source = parse_assistant_markdown(&source).visible_markdown; + let source = + parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown; self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), @@ -261,7 +262,7 @@ impl ChatWidget { AgentMessageContent::Text { text } => message.push_str(text), } } - let parsed = parse_assistant_markdown(&message); + let parsed = parse_assistant_markdown(&message, self.config.cwd.as_path()); self.finalize_completed_assistant_message( (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), ); diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index bbea783cf2..24674d710e 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -89,9 +89,9 @@ impl ChatWidget { // source only when no earlier item-level event (AgentMessageItem, plan // commit, review output) already recorded markdown for this turn. This // prevents the final summary from overwriting a more specific source. - let sanitized_last_agent_message = last_agent_message - .as_deref() - .map(|message| parse_assistant_markdown(message).visible_markdown); + let sanitized_last_agent_message = last_agent_message.as_deref().map(|message| { + parse_assistant_markdown(message, self.config.cwd.as_path()).visible_markdown + }); if let Some(message) = sanitized_last_agent_message .as_ref() .filter(|message| !message.is_empty()) diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs index 4dda4d451c..c54744c845 100644 --- a/codex-rs/tui/src/git_action_directives.rs +++ b/codex-rs/tui/src/git_action_directives.rs @@ -1,6 +1,8 @@ -//! Codex App git action directives embedded in assistant markdown. +//! Codex App directives embedded in assistant markdown. +use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub(crate) enum GitActionDirective { @@ -50,12 +52,16 @@ impl ParsedAssistantMarkdown { } } -pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdown { +pub(crate) fn parse_assistant_markdown(markdown: &str, cwd: &Path) -> ParsedAssistantMarkdown { let mut git_actions = Vec::new(); let mut seen = HashSet::new(); let mut visible_lines = Vec::new(); for line in markdown.lines() { + if let Some(rewritten) = rewrite_code_comment_line(line, cwd) { + visible_lines.push(rewritten.trim_end().to_string()); + continue; + } let (visible_line, line_actions) = strip_line_directives(line); for action in line_actions { if seen.insert(action.clone()) { @@ -78,6 +84,53 @@ pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdow } } +fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option { + let content = line.trim_start_matches([' ', '\t']); + let indent = &line[..line.len() - content.len()]; + let marker_length = content.bytes().take_while(|byte| *byte == b':').count(); + if !(1..=3).contains(&marker_length) { + return None; + } + + let directive = content[marker_length..].strip_prefix("code-comment{")?; + let (attributes, suffix) = directive.rsplit_once('}')?; + let attributes = parse_code_comment_attributes(attributes)?; + let title = attributes.get("title")?; + let body = attributes.get("body")?; + let file = attributes.get("file")?; + let title = title.trim(); + let body = body.trim(); + let file = file.trim(); + (!title.is_empty() && !body.is_empty() && !file.is_empty()).then_some(())?; + + let start = directive_integer(&attributes, "start").unwrap_or(1).max(1); + let end = directive_integer(&attributes, "end") + .unwrap_or(start) + .max(start); + let title = if title_has_priority(title) { + title.to_string() + } else if let Some(priority @ 0..=3) = directive_integer(&attributes, "priority") { + format!("[P{priority}] {title}") + } else { + title.to_string() + }; + let file_path = Path::new(file); + let file = file_path + .strip_prefix(cwd) + .unwrap_or(file_path) + .to_string_lossy() + .replace('\\', "/"); + let location = if start == end { + format!("{file}:{start}") + } else { + format!("{file}:{start}-{end}") + }; + + Some(format!( + "{indent}- {title} — {location}\n{indent} {body}{suffix}" + )) +} + fn strip_line_directives(line: &str) -> (String, Vec) { let mut visible = String::new(); let mut actions = Vec::new(); @@ -106,6 +159,46 @@ fn strip_line_directives(line: &str) -> (String, Vec) { (visible, actions) } +fn directive_integer(attributes: &HashMap, name: &str) -> Option { + attributes + .get(name)? + .trim() + .trim_start_matches(['P', 'p']) + .parse() + .ok() +} + +fn title_has_priority(title: &str) -> bool { + let bytes = title.trim_start().as_bytes(); + bytes.len() >= 4 + && bytes[0] == b'[' + && matches!(bytes[1], b'P' | b'p') + && bytes[2].is_ascii_digit() + && bytes[3] == b']' +} + +fn parse_code_comment_attributes(input: &str) -> Option> { + let mut attributes = HashMap::new(); + let mut rest = input.trim(); + while !rest.is_empty() { + let equals = rest.find('=')?; + let name = rest[..equals].trim(); + if name.is_empty() { + return None; + } + rest = rest[equals + 1..].trim_start(); + let (value, next) = if let Some(quoted) = rest.strip_prefix('"') { + parse_quoted_value(quoted)? + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + (rest[..end].to_string(), &rest[end..]) + }; + attributes.insert(name.to_string(), value); + rest = next.trim_start(); + } + Some(attributes) +} + fn parse_git_action(name: &str, attributes: &str) -> Option { let attrs = parse_attributes(attributes)?; let cwd = attrs.get("cwd")?.clone(); @@ -153,14 +246,36 @@ fn parse_attributes(input: &str) -> Option Option<(String, &str)> { + let mut value = String::new(); + let mut characters = input.char_indices().peekable(); + + while let Some((index, character)) = characters.next() { + if character == '"' { + return Some((value, &input[index + 1..])); + } + match character { + '\\' if characters.peek().is_some_and(|(_, next)| *next == '"') => { + value.push('"'); + characters.next(); + } + _ => value.push(character), + } + } + + None +} + #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn strips_and_parses_git_action_directives() { let parsed = parse_assistant_markdown( - "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"}", + "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"} ::git-stage{cwd=\"C:\\repo\\\"}", + Path::new("/repo"), ); assert_eq!(parsed.visible_markdown, "Done"); @@ -174,22 +289,50 @@ mod tests { cwd: "/repo".to_string(), branch: "feat/x".to_string(), }, + GitActionDirective::Stage { + cwd: "C:\\repo\\".to_string(), + }, ] ); } #[test] fn hides_malformed_directives_without_materializing_rows() { - let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}"); + let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}", Path::new("/repo")); assert_eq!(parsed.visible_markdown, "Done"); assert!(parsed.git_actions.is_empty()); } + #[test] + fn renders_code_comment_directives_as_markdown() { + let parsed = parse_assistant_markdown( + concat!( + "Found two issues.\n\n", + r#"::code-comment{title="Fix body= parsing" body="Keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"}"#, + "\n\n", + r#":::code-comment{title="[P1] Clamp the range" body="The line range should match the App." file="codex/src/range.ts" start=8 end=2 priority=3}"#, + ), + Path::new("/repo"), + ); + + insta::assert_snapshot!("code_comment_directive_fallback", parsed.visible_markdown); + assert!(parsed.git_actions.is_empty()); + } + + #[test] + fn preserves_non_directive_and_malformed_code_comment_text() { + let markdown = "Mention `::code-comment{title=\"Example\"}` inline.\n::code-comment{title=\"Missing body\" file=\"/repo/src/app.ts\"}"; + let parsed = parse_assistant_markdown(markdown, Path::new("/repo")); + + assert_eq!(parsed.visible_markdown, markdown); + } + #[test] fn last_created_branch_cwd_uses_the_last_matching_directive() { let parsed = parse_assistant_markdown( "::git-create-branch{cwd=\"/first\" branch=\"first\"}\n::git-push{cwd=\"/repo\" branch=\"first\"}\n::git-create-branch{cwd=\"/second\" branch=\"second\"}", + Path::new("/repo"), ); assert_eq!(parsed.last_created_branch_cwd(), Some("/second")); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c261ade7d2..de9a46e8ff 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -774,6 +774,7 @@ async fn load_transcript_preview( .thread_read(thread_id, /*include_turns*/ true) .await .map_err(std::io::Error::other)?; + let cwd = thread.cwd.as_path(); let mut lines = thread .turns .iter() @@ -794,7 +795,7 @@ async fn load_transcript_preview( }), ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { speaker: TranscriptPreviewSpeaker::Assistant, - text: parse_assistant_markdown(text).visible_markdown, + text: parse_assistant_markdown(text, cwd).visible_markdown, }), _ => None, }) diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs index abf13bd144..e211a71265 100644 --- a/codex-rs/tui/src/resume_picker/transcript.rs +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -67,7 +67,7 @@ pub(crate) fn thread_to_transcript_cells( })); } ThreadItem::AgentMessage { text, .. } => { - let parsed = parse_assistant_markdown(text); + let parsed = parse_assistant_markdown(text, cwd); if !parsed.visible_markdown.trim().is_empty() { cells.push(Arc::new(AgentMarkdownCell::new( parsed.visible_markdown, diff --git a/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap new file mode 100644 index 0000000000..630a3a542b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/git_action_directives.rs +expression: parsed.visible_markdown +--- +Found two issues. + +- [P2] Fix body= parsing — src/app.ts:10-12 + Keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. + +- [P1] Clamp the range — codex/src/range.ts:8 + The line range should match the App. From d5e4f01af4f7d3937ee4cd54ac27507fa421d755 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 18:18:29 +0200 Subject: [PATCH 068/141] feat: reload v2 agents on delivery (#26623) ## Summary This is the first small step toward making multi-agent v2 agents durable logical agents whose `ThreadManager` residency is only an implementation detail. This PR adds a narrow v2 reload-on-delivery hook: - If a known v2 agent target is already loaded, delivery is unchanged. - If the target is still registered but missing from `ThreadManager`, delivery reloads that exact v2 thread from durable rollout history before submitting the message. - If the target is unknown, closed, missing from storage, or not a v2 thread, delivery still fails as not found. The reload is wired only into existing-agent delivery paths: v2 `send_message` / `followup_task`, and legacy `send_input` when its target is a known v2 agent. ## Stack 1. **Reload on delivery**: load known unloaded v2 agents before `followup_task`, `send_message`, or `send_input` delivery. This PR. 2. **Residency LRU**: unload idle resident v2 agents from `ThreadManager` without making them closed or unreachable. 3. **Execution concurrency**: count active non-root turns, not logical agents or resident idle threads. 4. **Close semantics**: make v2 close interrupt-only and leave durable agent identity intact. 5. **Resume cleanup**: remove user-facing v2 resume semantics; addressing an unloaded durable agent reloads it implicitly. ## Validation - Ran `just fmt`. - Left broader tests and clippy to CI. --- codex-rs/core/src/agent/control/spawn.rs | 73 ++++++++++++ codex-rs/core/src/agent/control_tests.rs | 107 ++++++++++++++++++ .../tools/handlers/multi_agents/send_input.rs | 13 ++- .../handlers/multi_agents_v2/message_tool.rs | 19 +++- 4 files changed, 206 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 7cf5d00b4d..8df3b0a07f 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -109,6 +109,79 @@ impl AgentControl { .await } + pub(crate) async fn ensure_v2_agent_loaded( + &self, + config: Config, + thread_id: ThreadId, + ) -> CodexResult<()> { + let state = self.upgrade()?; + if state.get_thread(thread_id).await.is_ok() { + return Ok(()); + } + if self.state.agent_metadata_for_thread(thread_id).is_none() { + return Err(CodexErr::ThreadNotFound(thread_id)); + } + + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let stored_source = stored_thread.source.clone(); + let stored_parent_thread_id = stored_thread.parent_thread_id; + let history = stored_thread + .history + .ok_or(CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) { + return Err(CodexErr::ThreadNotFound(thread_id)); + } + + let (session_source, _) = initial_history + .get_resumed_session_sources() + .unwrap_or((stored_source, None)); + let parent_thread_id = initial_history + .get_resumed_parent_thread_id() + .or(stored_parent_thread_id); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + + match state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config, + initial_history, + agent_control: self.clone(), + session_source, + parent_thread_id, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await + { + Ok(reloaded_thread) => { + state.notify_thread_created(reloaded_thread.thread_id); + Ok(()) + } + Err(err) => { + if state.get_thread(thread_id).await.is_ok() { + return Ok(()); + } + Err(err) + } + } + } + async fn spawn_agent_internal( &self, config: Config, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3866d22013..c9cd444925 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,113 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { + let (home, mut config) = test_config().await; + let _ = config.features.enable(Feature::MultiAgentV2); + let _ = config.features.enable(Feature::Sqlite); + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + state_db, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + let child_thread = harness + .manager + .get_thread(spawned_agent.thread_id) + .await + .expect("child thread should exist"); + child_thread + .inject_response_items(vec![assistant_message( + "child persisted", + Some(MessagePhase::FinalAnswer), + )]) + .await + .expect("child rollout should persist with v2 metadata"); + child_thread + .shutdown_and_wait() + .await + .expect("child thread should shut down"); + + assert!( + harness + .manager + .remove_thread(&spawned_agent.thread_id) + .await + .is_some() + ); + match harness.manager.get_thread(spawned_agent.thread_id).await { + Err(CodexErr::ThreadNotFound(id)) => assert_eq!(id, spawned_agent.thread_id), + Err(err) => panic!("expected ThreadNotFound, got {err:?}"), + Ok(_) => panic!("expected thread to be removed"), + } + + harness + .control + .ensure_v2_agent_loaded(harness.config.clone(), spawned_agent.thread_id) + .await + .expect("known v2 agent should reload"); + let _ = harness + .manager + .get_thread(spawned_agent.thread_id) + .await + .expect("reloaded child thread should exist"); + + let communication = InterAgentCommunication::new( + AgentPath::root(), + agent_path, + Vec::new(), + "hello after reload".to_string(), + /*trigger_turn*/ false, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication.clone()) + .await + .expect("send_inter_agent_communication should succeed after reload"); + let expected = ( + spawned_agent.thread_id, + Op::InterAgentCommunication { communication }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + #[tokio::test] async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 1b7018939e..6e28d0a1d9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -42,8 +42,17 @@ impl ToolExecutor for Handler { let receiver_agent = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); + .get_agent_metadata(receiver_thread_id); + if receiver_agent.is_some() { + let resume_config = build_agent_resume_config(turn.as_ref())?; + session + .services + .agent_control + .ensure_v2_agent_loaded(resume_config, receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + let receiver_agent = receiver_agent.unwrap_or_default(); if args.interrupt { session .services diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index f23d700e9e..528fa55264 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -75,7 +75,11 @@ pub(crate) async fn handle_message_string_tool( .services .agent_control .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "agent with id {receiver_thread_id} not found" + )) + })?; if mode == MessageDeliveryMode::TriggerTurn && receiver_agent .agent_path @@ -86,6 +90,16 @@ pub(crate) async fn handle_message_string_tool( "Follow-up tasks can't target the root agent".to_string(), )); } + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) + })?; + let resume_config = build_agent_resume_config(turn.as_ref())?; + session + .services + .agent_control + .ensure_v2_agent_loaded(resume_config, receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; session .send_event( &turn, @@ -99,9 +113,6 @@ pub(crate) async fn handle_message_string_tool( .into(), ) .await; - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) - })?; let author = turn .session_source .get_agent_path() From 40c8f1a0072e04f2df049c2233a28543604fc448 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 09:29:15 -0700 Subject: [PATCH 069/141] Require absolute cwd in thread settings (#26532) ## Why Thread settings cwd overrides are expected to be resolved before they enter core. Keeping this boundary as a plain `PathBuf` made it easy for core/session code to keep fallback normalization and relative-path resolution logic in places that should only receive an already-resolved cwd. This is intentionally the absolute-cwd-only slice: it does not change environment selection stickiness or cwd-to-default-environment fallback behavior. ## What changed - Changes `ThreadSettingsOverrides.cwd`, `CodexThreadSettingsOverrides.cwd`, and `SessionSettingsUpdate.cwd` to use `AbsolutePathBuf`. - Removes core-side cwd normalization/resolution from session settings updates. - Updates affected core/app-server test helpers and callsites to pass existing absolute cwd values or use `abs()` helpers. ## Validation Opening as draft so CI can start while local validation continues. --- .../src/request_processors/turn_processor.rs | 18 ++++++++++--- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 2 +- codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 16 ++---------- codex-rs/core/src/session/tests.rs | 16 ++++++------ codex-rs/core/tests/common/test_codex.rs | 6 ++++- codex-rs/core/tests/suite/apply_patch_cli.rs | 2 +- codex-rs/core/tests/suite/approvals.rs | 8 +++--- codex-rs/core/tests/suite/auto_review.rs | 3 ++- codex-rs/core/tests/suite/client.rs | 4 +-- codex-rs/core/tests/suite/code_mode.rs | 2 +- .../tests/suite/collaboration_instructions.rs | 6 ++--- codex-rs/core/tests/suite/compact.rs | 6 +++-- codex-rs/core/tests/suite/compact_remote.rs | 5 ++-- .../core/tests/suite/compact_resume_fork.rs | 2 +- codex-rs/core/tests/suite/exec_policy.rs | 4 +-- codex-rs/core/tests/suite/guardian_review.rs | 2 +- codex-rs/core/tests/suite/image_rollout.rs | 5 ++-- codex-rs/core/tests/suite/items.rs | 3 ++- codex-rs/core/tests/suite/json_result.rs | 7 +++--- .../core/tests/suite/mcp_turn_metadata.rs | 2 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- .../core/tests/suite/model_visible_layout.rs | 8 +++++- codex-rs/core/tests/suite/models_cache_ttl.rs | 2 +- .../core/tests/suite/models_etag_responses.rs | 3 ++- codex-rs/core/tests/suite/override_updates.rs | 3 ++- codex-rs/core/tests/suite/pending_input.rs | 2 +- codex-rs/core/tests/suite/personality.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 10 ++++---- codex-rs/core/tests/suite/remote_env.rs | 2 +- codex-rs/core/tests/suite/remote_models.rs | 5 ++-- .../core/tests/suite/request_permissions.rs | 2 +- .../tests/suite/request_permissions_tool.rs | 2 +- .../core/tests/suite/request_user_input.rs | 7 +++--- .../suite/responses_api_proxy_headers.rs | 2 +- codex-rs/core/tests/suite/review.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 2 +- .../tests/suite/safety_check_downgrade.rs | 2 +- codex-rs/core/tests/suite/shell_snapshot.rs | 8 +++--- codex-rs/core/tests/suite/skill_approval.rs | 2 +- codex-rs/core/tests/suite/skills.rs | 2 +- codex-rs/core/tests/suite/sqlite_state.rs | 2 +- .../tests/suite/subagent_notifications.rs | 2 +- codex-rs/core/tests/suite/tool_harness.rs | 11 ++++---- codex-rs/core/tests/suite/tool_parallelism.rs | 4 +-- codex-rs/core/tests/suite/truncation.rs | 3 ++- codex-rs/core/tests/suite/unified_exec.rs | 25 ++++++++++--------- codex-rs/core/tests/suite/user_shell_cmd.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 2 +- .../core/tests/suite/websocket_fallback.rs | 3 ++- codex-rs/protocol/src/protocol.rs | 2 +- 52 files changed, 136 insertions(+), 112 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8ab6cf7731..85342235c9 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -32,6 +32,14 @@ fn resolve_runtime_workspace_roots( resolved_roots } +fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { + cwd.map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) + }) + .transpose() +} + fn map_additional_context( additional_context: Option>, ) -> BTreeMap { @@ -57,7 +65,7 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, - cwd: Option, + cwd: Option, runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, @@ -419,12 +427,13 @@ impl TurnRequestProcessor { let client_user_message_id = params.client_user_message_id; let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); + let cwd = resolve_request_cwd(params.cwd)?; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "turn/start", - cwd: params.cwd, + cwd, runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, @@ -572,7 +581,7 @@ impl TurnRequestProcessor { ))); }; let overrides = ConfigOverrides { - cwd: cwd.clone(), + cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( || { snapshot @@ -666,12 +675,13 @@ impl TurnRequestProcessor { params: ThreadSettingsUpdateParams, ) -> Result { let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let cwd = resolve_request_cwd(params.cwd)?; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "thread/settings/update", - cwd: params.cwd, + cwd, runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f9f10881b5..d87b65e8ae 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -127,7 +127,7 @@ impl ThreadConfigSnapshot { /// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { - pub cwd: Option, + pub cwd: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index d81f84590b..8504690b29 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -733,7 +733,7 @@ async fn run_review_on_session( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { #[allow(deprecated)] - cwd: Some(params.parent_turn.cwd.to_path_buf()), + cwd: Some(params.parent_turn.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: None, permission_profile: Some(guardian_permission_profile), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1b40bd7656..37d55256f5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,7 +32,6 @@ use crate::default_skill_metadata_budget; use crate::environment_selection::ResolvedTurnEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::parse_turn_item; -use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; use crate::session_prefix::format_subagent_notification_message; use crate::skills::SkillRenderSideEffects; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9314a743a5..e7ec3b98db 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -251,19 +251,7 @@ impl SessionConfiguration { next_configuration.windows_sandbox_level = windows_sandbox_level; } - 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 absolute_cwd = updates.cwd.clone().unwrap_or_else(|| self.cwd.clone()); let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; @@ -415,7 +403,7 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { - pub(crate) cwd: Option, + pub(crate) cwd: Option, pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a2023ea378..abe1d8f07a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3932,6 +3932,7 @@ async fn session_configuration_apply_preserves_profile_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 project_root = project_root.abs(); let docs_dir = docs_dir.abs(); session_configuration.cwd = original_cwd.abs(); @@ -4152,7 +4153,7 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(new_root.to_path_buf()), + cwd: Some(new_root.clone()), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4391,7 +4392,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.to_path_buf()), + cwd: Some(project_root.clone()), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4420,6 +4421,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up std::fs::create_dir_all(&original_cwd).expect("create original cwd"); std::fs::create_dir_all(&next_cwd).expect("create next cwd"); let original_cwd = original_cwd.abs(); + let next_cwd = next_cwd.abs(); session_configuration.cwd = original_cwd.clone(); let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -4480,7 +4482,7 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4516,7 +4518,7 @@ async fn relative_cwd_update_without_environments_resolves_under_session_cwd() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4545,7 +4547,7 @@ async fn cwd_update_does_not_rewrite_sticky_environment_cwd() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4572,7 +4574,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - cwd: Some(absolute_cwd.to_path_buf()), + cwd: Some(absolute_cwd.clone()), environments: Some(vec![TurnEnvironmentSelection { environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), cwd: absolute_cwd.clone(), @@ -5989,7 +5991,7 @@ async fn user_turn_updates_approvals_reviewer() { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), sandbox_policy: Some(config.legacy_sandbox_policy()), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 4fe0609a71..496bf03f57 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -772,7 +772,7 @@ impl TestCodex { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(self.config.cwd.to_path_buf()), + cwd: Some(self.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -846,6 +846,10 @@ impl TestCodexHarness { self.test.config.cwd.as_path() } + pub fn cwd_abs(&self) -> AbsolutePathBuf { + self.test.config.cwd.clone() + } + pub fn path(&self, rel: impl AsRef) -> PathBuf { self.path_abs(rel).into_path_buf() } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index da138851f5..1e2493a807 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -93,7 +93,7 @@ async fn submit_without_wait_with_turn_permissions( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(harness.cwd().to_path_buf()), + cwd: Some(harness.cwd_abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index aceac7320e..865aabb46c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -663,7 +663,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2624,7 +2624,7 @@ async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2769,7 +2769,7 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -3361,7 +3361,7 @@ allow_local_binding = true responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 64ae10d688..5de8f421b9 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::Op; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -150,7 +151,7 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re ) .await?; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_path.as_path()); codex diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 72e415e2be..7c9e07d230 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1799,7 +1799,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some( @@ -1987,7 +1987,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some(ReasoningSummary::Concise), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index bc311d4245..fca4556972 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3079,7 +3079,7 @@ text( ) .await; - let cwd = test.cwd.path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index bbbb0f1aa2..0d4f4cf6f8 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -174,7 +174,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -225,7 +225,7 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -334,7 +334,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 0e2c02de91..5fe90bf7a7 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +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; @@ -32,6 +33,7 @@ use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; +use core_test_support::test_path_buf; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use std::path::PathBuf; @@ -99,7 +101,7 @@ fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: S responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -3697,7 +3699,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index ebb78a0176..2200c7b778 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,7 +1,6 @@ #![allow(clippy::expect_used)] use std::fs; -use std::path::PathBuf; use anyhow::Result; use codex_core::compact::SUMMARY_PREFIX; @@ -24,6 +23,7 @@ use codex_protocol::protocol::RealtimeOutputModality; 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::apps_test_server::configure_search_capable_model; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -36,6 +36,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_path_buf; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; @@ -3313,7 +3314,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 54056b5b9d..47d1d1bb58 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -551,7 +551,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { core_test_support::submit_thread_settings( &conversation, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(override_cwd.to_path_buf()), + cwd: Some(override_cwd.clone()), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 9e350d2773..6859c6c884 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -56,7 +56,7 @@ async fn submit_user_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -144,7 +144,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index df38fb4353..32808dc434 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -120,7 +120,7 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 7018274510..bcfa0b7392 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -129,7 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -228,7 +229,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a56b2ab95f..a0010b6ffe 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -17,6 +17,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_image_generation_call; @@ -47,7 +48,7 @@ fn disabled_plan_turn( _model: String, collaboration_mode: CollaborationMode, ) -> anyhow::Result { - let cwd = std::env::current_dir()?; + let cwd = std::env::current_dir()?.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); Ok(Op::UserInput { diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 67275d3146..e65d290b87 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -69,9 +69,10 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { }; responses::mount_sse_once_match(&server, match_json_text_param, sse1).await; - let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?; + let TestCodex { codex, config, .. } = test_codex().build(&server).await?; + let cwd = config.cwd.clone(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, cwd.path()); + turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); // 1) Normal user input – should hit server once. codex @@ -85,7 +86,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 897d862162..b0c1910d60 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -78,7 +78,7 @@ async fn submit_user_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index a80c43660c..b0d4bb558d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -50,7 +50,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 27d92d2774..25dd52174d 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -11,6 +11,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +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; @@ -112,7 +113,8 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { let test = builder.build(&server).await?; let preturn_context_diff_cwd = test.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&preturn_context_diff_cwd)?; - let first_turn_cwd = test.cwd_path().to_path_buf(); + let preturn_context_diff_cwd = preturn_context_diff_cwd.abs(); + let first_turn_cwd = test.config.cwd.clone(); let (first_sandbox_policy, first_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), first_turn_cwd.as_path()); @@ -239,6 +241,8 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R cwd_two.join("AGENTS.md"), "# AGENTS two\n\n\nTurn two agents instructions.\n\n", )?; + let cwd_one = cwd_one.abs(); + let cwd_two = cwd_two.abs(); let (first_sandbox_policy, first_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_one.as_path()); @@ -397,6 +401,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; + let resume_override_cwd = resume_override_cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields( PermissionProfile::read_only(), resume_override_cwd.as_path(), @@ -508,6 +513,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; + let resume_override_cwd = resume_override_cwd.abs(); core_test_support::submit_thread_settings( &resumed.codex, codex_protocol::protocol::ThreadSettingsOverrides { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index d27c43cfc6..485a51346d 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -102,7 +102,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index cbd73fffe9..11935ca27f 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -62,7 +63,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch let codex = Arc::clone(&test.codex); let cwd = Arc::clone(&test.cwd); let session_model = test.session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index ce7c87a93b..e42703dff8 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use core_test_support::TempDirExt; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; @@ -67,7 +68,7 @@ async fn thread_settings_update_without_user_turn_does_not_record_environment_up core_test_support::submit_thread_settings( &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: Some(new_cwd.abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 96b89caec8..8a6443b6ce 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -124,7 +124,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index bf411d9cd1..4bd335bb35 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -70,7 +70,7 @@ fn read_only_text_turn_with_personality( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ea646a4dbc..dddfd4af68 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -767,7 +767,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: Some(new_cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -884,7 +884,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -913,7 +913,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1027,7 +1027,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1058,7 +1058,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 0862482f9d..9363bdd3bd 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -81,7 +81,7 @@ async fn submit_turn_with_approval_and_environments( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 0ed39216d0..76e7a1c29e 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -573,7 +574,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex @@ -801,7 +802,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { ) .await?; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index a78e4a016e..3beecfc33e 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -200,7 +200,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a122cc399b..9b8761c85c 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -152,7 +152,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index d09071d72d..50cabe416e 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; @@ -146,7 +147,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -290,7 +291,7 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -395,7 +396,7 @@ where responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 144feb016f..8492f08fdd 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -141,7 +141,7 @@ async fn responses_api_parent_and_subagent_requests_include_identity_headers() - async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> { let session_model = test.session_configured.model.clone(); - let cwd = test.config.cwd.to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::workspace_write(), cwd.as_path()); test.codex diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 0d3f564893..5f07d0b39c 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -821,7 +821,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(repo_path.to_path_buf()), + cwd: Some(repo_path.to_path_buf().abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 552c4ed529..9745254272 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -120,7 +120,7 @@ fn user_turn_with_permission_profile( model: String, permission_profile: PermissionProfile, ) -> Op { - let cwd = fixture.cwd.path().to_path_buf(); + let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, cwd.as_path()); Op::UserInput { diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 532f4821d9..d21299b1ea 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -47,7 +47,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 69db30b21b..61ca286005 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -154,7 +154,7 @@ async fn run_snapshot_command_with_options( let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -255,7 +255,7 @@ async fn run_shell_command_snapshot_with_options( let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -337,7 +337,7 @@ async fn run_tool_turn_on_harness( let test = harness.test(); let codex = test.codex.clone(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex @@ -554,7 +554,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let codex_home = test.home.path().to_path_buf(); let target = cwd.join("snapshot-apply.txt"); diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 2335ba6415..d60d719cc8 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -56,7 +56,7 @@ async fn submit_turn_with_policies( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index e21668fa84..c559615d4e 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -90,7 +90,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 94deeb48a0..9f8c940a0f 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -462,7 +462,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< wait_for_mcp_server(&test.codex, server_name).await?; let db = test.codex.state_db().expect("state db enabled"); let thread_id = test.session_configured.thread_id; - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 97d60d67c9..391b6e5236 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -773,7 +773,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a6f808b9d7..d628c8c49d 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; @@ -97,7 +98,7 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -179,7 +180,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -271,7 +272,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -373,7 +374,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -512,7 +513,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 76bb27c423..f7f046fef7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -47,7 +47,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -374,7 +374,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index db90b663e0..35a6b0df6d 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -528,7 +529,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(fixture.cwd.path().to_path_buf()), + cwd: Some(fixture.cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d86a0f620e..3eb2c6a6d3 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::managed_network_requirements_loader; use core_test_support::process::process_is_alive; @@ -202,7 +203,7 @@ async fn submit_unified_exec_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -278,7 +279,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let session_model = test.session_configured.model.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, &cwd); @@ -2135,9 +2136,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); + turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2239,9 +2240,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); + turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2712,9 +2713,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2836,9 +2837,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { items: vec![UserInput::Text { @@ -2974,9 +2975,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 3e2263705a..fe9715f61e 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -168,7 +168,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> ]); let mock = responses::mount_sse_sequence(&server, vec![first, second]).await; - let cwd = fixture.cwd.path().to_path_buf(); + let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 19758592c3..5d7263d54b 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -79,7 +79,7 @@ fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index be33467d6a..b41f8be963 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -5,6 +5,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -162,7 +163,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ed278d1843..cd183dae0c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -407,7 +407,7 @@ pub struct ConversationTextParams { pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, + pub cwd: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. From d40454522e2c6216eeeca5d97d39e991791ec664 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 5 Jun 2026 09:34:36 -0700 Subject: [PATCH 070/141] [codex] Allow socketpair in proxy-routed Linux sandbox (#26625) ## Summary - allow `socketpair(AF_UNIX, ...)` in the proxy-routed Linux seccomp mode - continue denying `socket(AF_UNIX, ...)` so user commands cannot create pathname or abstract Unix sockets - extend the managed-proxy integration test to verify both behaviors ## Root cause `NetworkSeccompMode::ProxyRouted` treated anonymous Unix socket pairs like externally addressable Unix sockets and returned `EPERM`. This breaks tools that use socket pairs for local child-process IPC even though a socket pair cannot connect outside the sandbox or bypass the routed proxy. `dangerously_allow_all_unix_sockets` controls Unix-socket requests forwarded by the managed network proxy; it does not currently configure the Linux seccomp filter. Socket pairs should not require that dangerous setting because they are unnamed, process-local IPC. Related but independent: #26553 fixes host proxy bridge socket path length handling. --------- Co-authored-by: Codex --- codex-rs/linux-sandbox/src/landlock.rs | 15 ++++++++------- .../linux-sandbox/tests/suite/managed_proxy.rs | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 3f95224ab6..65ba3ebf17 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -217,10 +217,11 @@ fn install_network_seccomp_filter_on_current_thread( } NetworkSeccompMode::ProxyRouted => { // In proxy-routed mode we allow IP sockets in the isolated - // namespace (used to reach the local TCP bridge) but deny all - // other socket families, including AF_UNIX. This prevents - // bypassing the routed bridge via new Unix sockets and narrows the - // socket surface in proxy-only mode. + // namespace (used to reach the local TCP bridge) but deny socket() + // for all other families, including AF_UNIX. Only AF_UNIX + // socketpair() remains available for process-local IPC because it + // cannot connect to a socket outside the sandbox or bypass the + // bridge. let deny_non_ip_socket = SeccompRule::new(vec![ SeccompCondition::new( 0, @@ -235,14 +236,14 @@ fn install_network_seccomp_filter_on_current_thread( libc::AF_INET6 as u64, )?, ])?; - let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( + let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, - SeccompCmpOp::Eq, + SeccompCmpOp::Ne, libc::AF_UNIX as u64, )?])?; rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]); - rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]); + rules.insert(libc::SYS_socketpair, vec![deny_non_unix_socketpair]); } } diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index d1aa6856c4..71ed971506 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -266,7 +266,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() { } #[tokio::test] -async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { +async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { if let Some(skip_reason) = managed_proxy_skip_reason().await { eprintln!("skipping managed proxy test: {skip_reason}"); return; @@ -292,7 +292,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { &[ "python3", "-c", - "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n", + "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n pass\nexcept OSError:\n sys.exit(2)\nelse:\n sys.exit(1)\nleft,right = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)\nleft.sendall(b'ok')\nif right.recv(2) != b'ok':\n sys.exit(3)\n", ], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, @@ -304,7 +304,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { assert_eq!( output.status.code(), Some(0), - "expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}", + "expected AF_UNIX socket creation to be denied and socketpair to work; status={:?}; stdout={}; stderr={}", output.status.code(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) From 9ddb1de633bde6d3d3e9b8012f822eb447db5195 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 09:38:26 -0700 Subject: [PATCH 071/141] [codex] Add /usr/bin/bash shell fallback (#26538) ## Why Some Linux environments expose `bash` at `/usr/bin/bash` instead of `/bin/bash`. The shell detection fallback list should cover both standard locations once PATH/user-shell probing fails. Stacked on #26480. ## What changed - Add `/usr/bin/bash` to the bash fallback path list in `codex-shell-command`. - Extend shell type detection coverage for `/usr/bin/bash`. - Add AGENTS.md testing guidance to avoid tests for statically defined values and negative tests for removed logic. ## Verification - `just test -p codex-shell-command` --- AGENTS.md | 2 ++ codex-rs/shell-command/src/shell_detect.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 88d3fc4eea..4666566f96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ In the codex-rs folder where the rust code lives: - Implementations may still use `async fn foo(&self, ...) -> T` when they satisfy that contract. - Do not use `#[allow(async_fn_in_trait)]` as a shortcut around spelling the future contract explicitly. - When writing tests, prefer comparing the equality of entire objects over fields one by one. +- Do not add tests for values that are statically defined. +- Do not add negative tests for logic that was removed. - Do not add general product or user-facing documentation to the `docs/` folder. The official Codex documentation lives elsewhere. The exception is app-server API documentation, which is covered by the app-server guidance below. - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 7068463830..69a66f0563 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -174,7 +174,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { }) } -const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; +const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash", "/usr/bin/bash"]; fn get_bash_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); @@ -327,6 +327,10 @@ mod tests { detect_shell_type(PathBuf::from("/bin/bash")), Some(ShellType::Bash) ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/bin/bash")), + Some(ShellType::Bash) + ); assert_eq!( detect_shell_type(PathBuf::from("powershell.exe")), Some(ShellType::PowerShell) From da490ba9de80bf83ad04f8db4cf72b793e99967f Mon Sep 17 00:00:00 2001 From: hefuc-oai Date: Fri, 5 Jun 2026 10:07:25 -0700 Subject: [PATCH 072/141] feat(remote-control): add pairing status transport (#26449) ## What Adds transport support for checking remote-control pairing status against the backend. - Adds the normalized `server/pair/status` backend URL. - Adds backend request/response structs for exactly one lookup key: `pairing_code` or `manual_pairing_code`, returning `{ claimed }`. - Adds `RemoteControlEnrollment::pairing_status` and `RemoteControlHandle::pairing_status`. - Preserves auth refresh/retry behavior and backend error mapping. - Adds transport coverage for pending, claimed, manual-code payloads, token refresh, mapped backend errors, malformed responses, and URL normalization. ## Why Desktop needs a host-authenticated way to poll whether a QR or manual pairing code has been claimed. Related backend change: https://github.com/openai/openai/pull/990244 ## Verification - `cargo test --manifest-path app-server-transport/Cargo.toml remote_control::tests::pairing_tests` - `cargo fmt --all --check` - `git diff --check` --- .../src/protocol/v2/remote_control.rs | 17 ++ .../src/transport/remote_control/enroll.rs | 66 ++++++ .../src/transport/remote_control/mod.rs | 114 +++++++++ .../src/transport/remote_control/protocol.rs | 51 +++++ .../src/transport/remote_control/tests.rs | 1 + .../remote_control/tests/pairing_tests.rs | 216 ++++++++++++++++++ 6 files changed, 465 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index f5f004f784..0e1809805d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -62,6 +62,23 @@ pub struct RemoteControlPairingStartResponse { pub expires_at: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStatusParams { + #[ts(optional = nullable)] + pub pairing_code: Option, + #[ts(optional = nullable)] + pub manual_pairing_code: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStatusResponse { + pub claimed: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index c491596e65..b4506a3d4c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -3,11 +3,14 @@ use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RefreshRemoteServerRequest; +use super::protocol::RemoteControlPairingStatusRequest; +use super::protocol::RemoteControlPairingStatusResponse as BackendRemoteControlPairingStatusResponse; use super::protocol::RemoteControlTarget; use super::protocol::StartRemoteControlPairingRequest; use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; @@ -136,6 +139,69 @@ impl RemoteControlEnrollment { }) } + pub(super) async fn pairing_status( + &self, + request: RemoteControlPairingStatusRequest, + ) -> io::Result { + if self.should_refresh_server_token() { + return Err(pairing_unavailable_error()); + } + let remote_control_token = self + .remote_control_token + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + + let response = build_reqwest_client() + .post(&self.remote_control_target.pair_status_url) + .timeout(REMOTE_CONTROL_PAIRING_TIMEOUT) + .bearer_auth(remote_control_token) + .json(&request) + .send() + .await + .map_err(|err| { + io::Error::other(format!( + "failed to check remote control pairing status at `{}`: {err}", + self.remote_control_target.pair_status_url + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control pairing status response from `{}`: {err}", + self.remote_control_target.pair_status_url + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let error_kind = match status.as_u16() { + 401 | 403 => ErrorKind::PermissionDenied, + 404 | 410 => ErrorKind::InvalidInput, + _ => ErrorKind::Other, + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control pairing status failed at `{}`: HTTP {status}, {}, body: {body_preview}", + self.remote_control_target.pair_status_url, + format_headers(&headers) + ), + )); + } + + let response = serde_json::from_slice::(&body) + .map_err(|err| { + io::Error::other(format!( + "failed to parse remote control pairing status response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + self.remote_control_target.pair_status_url, + format_headers(&headers) + )) + })?; + Ok(RemoteControlPairingStatusResponse { + claimed: response.claimed, + }) + } + pub(super) fn should_refresh_server_token(&self) -> bool { self.remote_control_token.is_none() || self.expires_at.is_none_or(|expires_at| { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 443dcc897e..6e8f31d45d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -18,6 +18,7 @@ use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; pub use self::protocol::ClientId; +use self::protocol::RemoteControlPairingStatusCode; use self::protocol::ServerEvent; use self::protocol::StreamId; use self::protocol::normalize_remote_control_url; @@ -31,6 +32,8 @@ use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; @@ -391,6 +394,89 @@ impl RemoteControlHandle { Ok(self.pairing_persistence_key.borrow().clone()) } + pub async fn pairing_status( + &self, + params: RemoteControlPairingStatusParams, + ) -> io::Result { + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let mut auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + let app_server_client_name = self.pairing_persistence_key.borrow().clone(); + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + .ok_or_else(pairing_unavailable_error)?; + let installation_id = self.status().installation_id; + if enrollment.should_refresh_server_token() { + refresh_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + } + let status_code = remote_control_pairing_status_code(¶ms)?; + let pairing_status_request = + || protocol::RemoteControlPairingStatusRequest::from(status_code.clone()); + let pairing_status_response = + match enrollment.pairing_status(pairing_status_request()).await { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; + refresh_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + enrollment.pairing_status(pairing_status_request()).await + } + pairing_status_response => pairing_status_response, + }; + if let Err(err) = &pairing_status_response { + match err.kind() { + io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + return Err(pairing_unavailable_error()); + } + io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; + return Err(pairing_unavailable_error()); + } + _ => {} + } + } + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let current_auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if current_auth.account_id != auth.account_id { + return Err(pairing_unavailable_error()); + } + pairing_status_response + } + pub async fn list_clients( &self, params: RemoteControlClientsListParams, @@ -407,6 +493,13 @@ impl RemoteControlHandle { .await } + fn pairing_disabled_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing requires remote control to be enabled", + ) + } + fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -464,6 +557,27 @@ async fn enroll_pairing_server( enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await } +fn remote_control_pairing_status_code( + params: &RemoteControlPairingStatusParams, +) -> io::Result { + match (¶ms.pairing_code, ¶ms.manual_pairing_code) { + (Some(pairing_code), None) => Ok(RemoteControlPairingStatusCode::PairingCode( + pairing_code.clone(), + )), + (None, Some(manual_pairing_code)) => Ok(RemoteControlPairingStatusCode::ManualPairingCode( + manual_pairing_code.clone(), + )), + (Some(_), Some(_)) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing status accepts either pairingCode or manualPairingCode, not both", + )), + (None, None) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing status requires pairingCode or manualPairingCode", + )), + } +} + async fn refresh_pairing_enrollment( current_enrollment: &mut Option, state_db: Option<&StateRuntime>, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index 630fd862c8..e3c122c1f7 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -13,6 +13,7 @@ pub(super) struct RemoteControlTarget { pub(super) enroll_url: String, pub(super) refresh_url: String, pub(super) pair_url: String, + pub(super) pair_status_url: String, } #[derive(Debug, Serialize)] @@ -52,6 +53,40 @@ pub(super) struct StartRemoteControlPairingResponse { pub(super) expires_at: String, } +#[derive(Debug, Serialize)] +pub(super) struct RemoteControlPairingStatusRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pairing_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) manual_pairing_code: Option, +} + +#[derive(Clone)] +pub(super) enum RemoteControlPairingStatusCode { + PairingCode(String), + ManualPairingCode(String), +} + +impl From for RemoteControlPairingStatusRequest { + fn from(code: RemoteControlPairingStatusCode) -> Self { + match code { + RemoteControlPairingStatusCode::PairingCode(pairing_code) => Self { + pairing_code: Some(pairing_code), + manual_pairing_code: None, + }, + RemoteControlPairingStatusCode::ManualPairingCode(manual_pairing_code) => Self { + pairing_code: None, + manual_pairing_code: Some(manual_pairing_code), + }, + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteControlPairingStatusResponse { + pub(super) claimed: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ClientId(pub String); @@ -194,6 +229,9 @@ pub(super) fn normalize_remote_control_url( let pair_url = remote_control_url .join("wham/remote/control/server/pair") .map_err(map_url_parse_error)?; + let pair_status_url = remote_control_url + .join("wham/remote/control/server/pair/status") + .map_err(map_url_parse_error)?; let mut websocket_url = remote_control_url .join("wham/remote/control/server") .map_err(map_url_parse_error)?; @@ -215,6 +253,7 @@ pub(super) fn normalize_remote_control_url( enroll_url: enroll_url.to_string(), refresh_url: refresh_url.to_string(), pair_url: pair_url.to_string(), + pair_status_url: pair_status_url.to_string(), }) } @@ -269,6 +308,9 @@ mod tests { .to_string(), pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://chatgpt.com/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); assert_eq!( @@ -287,6 +329,9 @@ mod tests { pair_url: "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); } @@ -305,6 +350,9 @@ mod tests { .to_string(), pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "http://localhost:8080/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); assert_eq!( @@ -320,6 +368,9 @@ mod tests { .to_string(), pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://localhost:8443/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 3e9a911b2d..91c13515d3 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_config::types::AuthCredentialsStoreMode; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 7586f0b4d1..fcdd5e30f2 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -1,6 +1,8 @@ +use super::super::protocol::RemoteControlPairingStatusRequest; use super::super::protocol::StartRemoteControlPairingRequest; use super::*; use pretty_assertions::assert_eq; +use std::io; fn remote_control_enrollment( remote_control_url: &str, @@ -66,6 +68,36 @@ async fn pairing_response_error(body: serde_json::Value) -> String { err.to_string() } +async fn pairing_status_error(status: &'static str, body: &'static str) -> (io::Error, String) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let expected_status_url = normalize_remote_control_url(&remote_control_url) + .expect("target should normalize") + .pair_status_url; + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + respond_with_status_and_headers( + status_request.stream, + status, + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + body, + ) + .await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect_err("pairing status should fail"); + server_task.await.expect("server task should finish"); + (err, expected_status_url) +} + #[tokio::test] async fn remote_control_handle_starts_pairing_before_websocket_connects() { let listener = TcpListener::bind("127.0.0.1:0") @@ -156,6 +188,190 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { ); } +#[tokio::test] +async fn remote_control_pairing_status_returns_pending() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + status_request.headers.get("authorization"), + Some(&"Bearer remote-control-token".to_string()) + ); + assert_eq!( + serde_json::from_str::(&status_request.body) + .expect("status request body should deserialize"), + json!({ "pairing_code": "pairing-code" }) + ); + respond_with_json(status_request.stream, json!({ "claimed": false })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(!response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_accepts_manual_pairing_code() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&status_request.body) + .expect("status request body should deserialize"), + json!({ "manual_pairing_code": "ABCD-EFGH" }) + ); + respond_with_json(status_request.stream, json!({ "claimed": false })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: None, + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(!response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_returns_claimed() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + respond_with_json(status_request.stream, json!({ "claimed": true })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(response.claimed); +} + +#[tokio::test] +async fn remote_control_handle_refreshes_after_pairing_status_auth_failure() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_status_request = accept_http_request(&listener).await; + assert_eq!( + stale_status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + stale_status_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_status_request.stream, "401 Unauthorized", "").await; + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_status_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + refreshed_status_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json(refreshed_status_request.stream, json!({ "claimed": true })).await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + + let response = remote_handle + .pairing_status(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should refresh after server token auth failure"); + server_task.await.expect("server task should finish"); + + assert!(response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_maps_user_actionable_backend_errors() { + for (status, expected_kind) in [ + ("403 Forbidden", io::ErrorKind::PermissionDenied), + ("404 Not Found", io::ErrorKind::InvalidInput), + ("410 Gone", io::ErrorKind::InvalidInput), + ] { + let (err, _expected_status_url) = pairing_status_error(status, "not available").await; + assert_eq!(err.kind(), expected_kind); + } +} + +#[tokio::test] +async fn remote_control_pairing_status_preserves_decode_error_context() { + let (err, expected_status_url) = pairing_status_error("200 OK", "{").await; + let err = err.to_string(); + + assert!(err.contains(&format!( + "failed to parse remote control pairing status response from `{expected_status_url}`: HTTP 200 OK" + ))); + assert!(err.contains("request-id: request-123")); + assert!(err.contains("cf-ray: ray-123")); + assert!(err.contains("body: {")); + assert!(err.contains("decode error:")); +} + #[tokio::test] async fn remote_control_handle_refreshes_after_pairing_auth_failure() { let listener = TcpListener::bind("127.0.0.1:0") From 86a1ddd0282f83f21d2e5743ffd1d6ba36725ed1 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 10:31:22 -0700 Subject: [PATCH 073/141] Make turn diff tracker multi-env aware (#26433) ## Why Turn diffs were tracked as one flat set of absolute paths. In multi-environment turns, local and remote environments can report the same path while representing different filesystems, so a single path key can collapse distinct changes or attribute them to the wrong environment. The environment name is **NOT** included in the generated unified diff. This can come later. --- codex-rs/core/src/session/turn.rs | 37 ++-- codex-rs/core/src/tools/events.rs | 68 +++++-- .../core/src/tools/handlers/apply_patch.rs | 13 +- codex-rs/core/src/turn_diff_tracker.rs | 148 ++++++++------- codex-rs/core/src/turn_diff_tracker_tests.rs | 85 ++++++--- codex-rs/core/tests/suite/apply_patch_cli.rs | 168 ++++++++++++++++++ 6 files changed, 400 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 63195d9fa7..5853b78c7e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::collections::HashSet; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -73,7 +74,6 @@ use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; -use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; @@ -196,21 +196,11 @@ pub(crate) async fn run_turn( let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. - #[allow(deprecated)] - let display_root = match turn_context.environments.primary() { - Some(turn_environment) => get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - &turn_environment.cwd, - ) - .await - .unwrap_or_else(|| turn_environment.cwd.clone()) - .into_path_buf(), - None => get_git_repo_root(turn_context.cwd.as_path()) - .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()), - }; - let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( - display_root, - ))); + let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new( + TurnDiffTracker::with_environment_display_roots( + turn_diff_display_roots(turn_context.as_ref()).await, + ), + )); // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse // one instance across retries within this turn. @@ -421,6 +411,21 @@ pub(crate) async fn run_turn( last_agent_message } +async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { + let mut display_roots = Vec::new(); + for turn_environment in &turn_context.environments.turn_environments { + let root = get_git_repo_root_with_fs( + turn_environment.environment.get_filesystem().as_ref(), + &turn_environment.cwd, + ) + .await + .unwrap_or_else(|| turn_environment.cwd.clone()) + .into_path_buf(); + display_roots.push((turn_environment.environment_id.clone(), root)); + } + display_roots +} + async fn run_hooks_and_record_inputs( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 747143ff69..e286af53d4 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -70,16 +70,25 @@ pub(crate) enum ToolEventFailure<'a> { } enum TurnDiffTrackerUpdate<'a> { - Track(&'a AppliedPatchDelta), + Track { + environment_id: Option, + delta: &'a AppliedPatchDelta, + }, Invalidate, None, } -fn tracker_update_for_known_delta(delta: &AppliedPatchDelta) -> TurnDiffTrackerUpdate<'_> { +fn tracker_update_for_known_delta<'a>( + environment_id: Option<&str>, + delta: &'a AppliedPatchDelta, +) -> TurnDiffTrackerUpdate<'a> { if delta.is_exact() && delta.is_empty() { TurnDiffTrackerUpdate::None } else { - TurnDiffTrackerUpdate::Track(delta) + TurnDiffTrackerUpdate::Track { + environment_id: environment_id.map(str::to_string), + delta, + } } } @@ -120,6 +129,7 @@ pub(crate) enum ToolEmitter { ApplyPatch { changes: HashMap, auto_approved: bool, + environment_id: Option, }, UnifiedExec { command: Vec, @@ -141,10 +151,15 @@ impl ToolEmitter { } } - pub fn apply_patch(changes: HashMap, auto_approved: bool) -> Self { + pub fn apply_patch_for_environment( + changes: HashMap, + auto_approved: bool, + environment_id: String, + ) -> Self { Self::ApplyPatch { changes, auto_approved, + environment_id: Some(environment_id), } } @@ -210,7 +225,11 @@ impl ToolEmitter { .await; } ( - Self::ApplyPatch { changes, .. }, + Self::ApplyPatch { + changes, + environment_id, + .. + }, ToolEventStage::Success { output, applied_patch_delta, @@ -222,7 +241,7 @@ impl ToolEmitter { PatchApplyStatus::Failed }; let tracker_update = applied_patch_delta - .map(tracker_update_for_known_delta) + .map(|delta| tracker_update_for_known_delta(environment_id.as_deref(), delta)) .unwrap_or(TurnDiffTrackerUpdate::Invalidate); emit_patch_end( ctx, @@ -267,7 +286,11 @@ impl ToolEmitter { .await; } ( - Self::ApplyPatch { changes, .. }, + Self::ApplyPatch { + changes, + environment_id, + .. + }, ToolEventStage::Failure(ToolEventFailure::Rejected { message, applied_patch_delta, @@ -280,7 +303,9 @@ impl ToolEmitter { (*message).to_string(), PatchApplyStatus::Declined, applied_patch_delta - .map(tracker_update_for_known_delta) + .map(|delta| { + tracker_update_for_known_delta(environment_id.as_deref(), delta) + }) .unwrap_or(TurnDiffTrackerUpdate::None), ) .await; @@ -565,8 +590,11 @@ async fn emit_patch_end( let mut guard = tracker.lock().await; let previous_diff = guard.get_unified_diff(); let tracker_changed = match tracker_update { - TurnDiffTrackerUpdate::Track(delta) => { - guard.track_delta(delta); + TurnDiffTrackerUpdate::Track { + environment_id, + delta, + } => { + guard.track_delta(environment_id.as_deref().unwrap_or_default(), delta); true } TurnDiffTrackerUpdate::Invalidate => { @@ -627,14 +655,18 @@ mod tests { .await .expect("apply patch"); - ToolEmitter::apply_patch(HashMap::new(), /*auto_approved*/ false) - .finish( - ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)), - out, - Some(&delta), - ) - .await - .expect_err("failed patch"); + ToolEmitter::ApplyPatch { + changes: HashMap::new(), + auto_approved: false, + environment_id: None, + } + .finish( + ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)), + out, + Some(&delta), + ) + .await + .expect_err("failed patch"); let completed = rx_event.recv().await.expect("item completed event"); assert!(matches!( diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index c12ba0517e..66fb1d3080 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -378,8 +378,11 @@ impl ToolExecutor for ApplyPatchHandler { } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let emitter = - ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); + let emitter = ToolEmitter::apply_patch_for_environment( + changes.clone(), + apply.auto_approved, + turn_environment.environment_id.clone(), + ); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), @@ -537,7 +540,11 @@ pub(crate) async fn intercept_apply_patch( } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); + let emitter = ToolEmitter::apply_patch_for_environment( + changes.clone(), + apply.auto_approved, + turn_environment.environment_id.clone(), + ); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 3835ae2345..388c2210bd 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -13,21 +13,36 @@ const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const DEV_NULL: &str = "/dev/null"; const REGULAR_FILE_MODE: &str = "100644"; +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct TrackedPath { + environment_id: String, + path: PathBuf, +} + +impl TrackedPath { + fn new(environment_id: &str, path: &Path) -> Self { + Self { + environment_id: environment_id.to_string(), + path: path.to_path_buf(), + } + } +} + /// Tracks the net text diff for the current turn from committed apply_patch /// mutations, without rereading the workspace filesystem. pub struct TurnDiffTracker { valid: bool, - display_root: Option, - baseline_by_path: HashMap, - current_by_path: HashMap, - origin_by_current_path: HashMap, + display_roots_by_environment: HashMap, + baseline_by_path: HashMap, + current_by_path: HashMap, + origin_by_current_path: HashMap, } impl Default for TurnDiffTracker { fn default() -> Self { Self { valid: true, - display_root: None, + display_roots_by_environment: HashMap::new(), baseline_by_path: HashMap::new(), current_by_path: HashMap::new(), origin_by_current_path: HashMap::new(), @@ -40,20 +55,22 @@ impl TurnDiffTracker { Self::default() } - pub fn with_display_root(display_root: PathBuf) -> Self { + pub fn with_environment_display_roots( + display_roots: impl IntoIterator, + ) -> Self { let mut tracker = Self::new(); - tracker.display_root = Some(display_root); + tracker.display_roots_by_environment = display_roots.into_iter().collect(); tracker } - pub fn track_delta(&mut self, delta: &AppliedPatchDelta) { + pub fn track_delta(&mut self, environment_id: &str, delta: &AppliedPatchDelta) { if !delta.is_exact() { self.invalidate(); return; } for change in delta.changes() { - self.apply_change(change); + self.apply_change(environment_id, change); } } @@ -106,8 +123,8 @@ impl TurnDiffTracker { (!aggregated.is_empty()).then_some(aggregated) } - fn apply_change(&mut self, change: &AppliedPatchChange) { - let source_path = change.path.as_path(); + fn apply_change(&mut self, environment_id: &str, change: &AppliedPatchChange) { + let source_path = TrackedPath::new(environment_id, change.path.as_path()); match &change.change { AppliedPatchFileChange::Add { content, @@ -119,85 +136,87 @@ impl TurnDiffTracker { old_content, overwritten_move_content, new_content, - } => self.apply_update( - source_path, - move_path.as_deref(), - old_content, - overwritten_move_content.as_deref(), - new_content, - ), + } => { + let move_path = move_path + .as_deref() + .map(|path| TrackedPath::new(environment_id, path)); + self.apply_update( + source_path, + move_path, + old_content, + overwritten_move_content.as_deref(), + new_content, + ) + } } } - fn apply_add(&mut self, path: &Path, content: &str, overwritten_content: Option<&str>) { - self.origin_by_current_path.remove(path); - if !self.current_by_path.contains_key(path) - && !self.baseline_by_path.contains_key(path) + fn apply_add(&mut self, path: TrackedPath, content: &str, overwritten_content: Option<&str>) { + self.origin_by_current_path.remove(&path); + if !self.current_by_path.contains_key(&path) + && !self.baseline_by_path.contains_key(&path) && let Some(overwritten_content) = overwritten_content { self.baseline_by_path - .insert(path.to_path_buf(), overwritten_content.to_string()); + .insert(path.clone(), overwritten_content.to_string()); } - self.current_by_path - .insert(path.to_path_buf(), content.to_string()); + self.current_by_path.insert(path, content.to_string()); } - fn apply_delete(&mut self, path: &Path, content: &str) { - if self.current_by_path.remove(path).is_none() && !self.baseline_by_path.contains_key(path) + fn apply_delete(&mut self, path: TrackedPath, content: &str) { + if self.current_by_path.remove(&path).is_none() + && !self.baseline_by_path.contains_key(&path) { self.baseline_by_path - .insert(path.to_path_buf(), content.to_string()); + .insert(path.clone(), content.to_string()); } - self.origin_by_current_path.remove(path); + self.origin_by_current_path.remove(&path); } fn apply_update( &mut self, - source_path: &Path, - move_path: Option<&Path>, + source_path: TrackedPath, + move_path: Option, old_content: &str, overwritten_move_content: Option<&str>, new_content: &str, ) { - if !self.current_by_path.contains_key(source_path) - && !self.baseline_by_path.contains_key(source_path) + if !self.current_by_path.contains_key(&source_path) + && !self.baseline_by_path.contains_key(&source_path) { self.baseline_by_path - .insert(source_path.to_path_buf(), old_content.to_string()); + .insert(source_path.clone(), old_content.to_string()); } match move_path { Some(dest_path) => { - if !self.current_by_path.contains_key(dest_path) - && !self.baseline_by_path.contains_key(dest_path) + if !self.current_by_path.contains_key(&dest_path) + && !self.baseline_by_path.contains_key(&dest_path) && let Some(overwritten_move_content) = overwritten_move_content { - self.baseline_by_path.insert( - dest_path.to_path_buf(), - overwritten_move_content.to_string(), - ); + self.baseline_by_path + .insert(dest_path.clone(), overwritten_move_content.to_string()); } let origin = self .origin_by_current_path - .remove(source_path) - .unwrap_or_else(|| source_path.to_path_buf()); - self.current_by_path.remove(source_path); + .remove(&source_path) + .unwrap_or_else(|| source_path.clone()); + self.current_by_path.remove(&source_path); self.current_by_path - .insert(dest_path.to_path_buf(), new_content.to_string()); - self.origin_by_current_path.remove(dest_path); - if dest_path != origin.as_path() { - self.origin_by_current_path - .insert(dest_path.to_path_buf(), origin); + .insert(dest_path.clone(), new_content.to_string()); + self.origin_by_current_path.remove(&dest_path); + if dest_path != origin { + self.origin_by_current_path.insert(dest_path, origin); } } None => { self.current_by_path - .insert(source_path.to_path_buf(), new_content.to_string()); + .insert(source_path, new_content.to_string()); } } } - fn rename_pairs(&self) -> HashMap { + fn rename_pairs(&self) -> HashMap { self.origin_by_current_path .iter() .filter_map(|(dest_path, origin_path)| { @@ -215,7 +234,7 @@ impl TurnDiffTracker { .collect() } - fn render_path_diff(&self, path: &Path) -> Option { + fn render_path_diff(&self, path: &TrackedPath) -> Option { self.render_diff( path, self.baseline_by_path.get(path).map(String::as_str), @@ -224,7 +243,11 @@ impl TurnDiffTracker { ) } - fn render_rename_diff(&self, source_path: &Path, dest_path: &Path) -> Option { + fn render_rename_diff( + &self, + source_path: &TrackedPath, + dest_path: &TrackedPath, + ) -> Option { self.render_diff( source_path, self.baseline_by_path.get(source_path).map(String::as_str), @@ -235,9 +258,9 @@ impl TurnDiffTracker { fn render_diff( &self, - left_path: &Path, + left_path: &TrackedPath, left_content: Option<&str>, - right_path: &Path, + right_path: &TrackedPath, right_content: Option<&str>, ) -> Option { if left_content == right_content { @@ -286,13 +309,18 @@ impl TurnDiffTracker { Some(diff) } - fn display_path(&self, path: &Path) -> String { + fn display_path(&self, path: &TrackedPath) -> String { let display = self - .display_root - .as_deref() - .and_then(|root| path.strip_prefix(root).ok()) - .unwrap_or(path); - display.display().to_string().replace('\\', "/") + .display_roots_by_environment + .get(&path.environment_id) + .and_then(|root| path.path.strip_prefix(root).ok()) + .unwrap_or(path.path.as_path()); + let display = display.display().to_string().replace('\\', "/"); + if self.display_roots_by_environment.len() > 1 && !path.environment_id.is_empty() { + format!("{}/{display}", path.environment_id) + } else { + display + } } } diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index d25fec2aad..6454430404 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -41,24 +41,28 @@ async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { .expect("patch should apply") } +fn tracker_with_root(root: &Path) -> TurnDiffTracker { + TurnDiffTracker::with_environment_display_roots([("".to_string(), root.to_path_buf())]) +} + #[tokio::test] async fn accumulates_add_then_update_as_single_add() { let dir = tempdir().expect("tempdir"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let update = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n@@\n foo\n+bar\n*** End Patch", ) .await; - tracker.track_delta(&update); + tracker.track_delta("", &update); let right_oid = git_blob_sha1_hex("foo\nbar\n"); let expected = format!( @@ -78,32 +82,69 @@ index {ZERO_OID}..{right_oid} #[tokio::test] async fn invalidated_tracker_suppresses_existing_diff() { let dir = tempdir().expect("tempdir"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); tracker.invalidate(); assert_eq!(tracker.get_unified_diff(), None); } +#[tokio::test] +async fn tracks_same_absolute_path_across_multiple_environments() { + let dir = tempdir().expect("tempdir"); + let add = apply_verified_patch( + dir.path(), + "*** Begin Patch\n*** Add File: shared.txt\n+content\n*** End Patch", + ) + .await; + + let mut tracker = TurnDiffTracker::with_environment_display_roots([ + ("local".to_string(), dir.path().to_path_buf()), + ("remote".to_string(), dir.path().to_path_buf()), + ]); + tracker.track_delta("remote", &add); + tracker.track_delta("local", &add); + + let right_oid = git_blob_sha1_hex("content\n"); + let expected = format!( + r#"diff --git a/local/shared.txt b/local/shared.txt +new file mode {REGULAR_FILE_MODE} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b/local/shared.txt +@@ -0,0 +1 @@ ++content +diff --git a/remote/shared.txt b/remote/shared.txt +new file mode {REGULAR_FILE_MODE} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b/remote/shared.txt +@@ -0,0 +1 @@ ++content +"#, + ); + assert_eq!(tracker.get_unified_diff(), Some(expected)); +} + #[tokio::test] async fn accumulates_delete() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("b.txt"), "x\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let delete = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: b.txt\n*** End Patch", ) .await; - tracker.track_delta(&delete); + tracker.track_delta("", &delete); let left_oid = git_blob_sha1_hex("x\n"); let expected = format!( @@ -124,13 +165,13 @@ async fn accumulates_move_and_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("src.txt"), "line\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let update = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: src.txt\n*** Move to: dst.txt\n@@\n-line\n+line2\n*** End Patch", ) .await; - tracker.track_delta(&update); + tracker.track_delta("", &update); let left_oid = git_blob_sha1_hex("line\n"); let right_oid = git_blob_sha1_hex("line2\n"); @@ -152,13 +193,13 @@ async fn pure_rename_yields_no_diff() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("old.txt"), "same\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let rename = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n same\n*** End Patch", ) .await; - tracker.track_delta(&rename); + tracker.track_delta("", &rename); assert_eq!(tracker.get_unified_diff(), None); } @@ -168,13 +209,13 @@ async fn add_over_existing_file_becomes_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("dup.txt"), "before\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: dup.txt\n+after\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let left_oid = git_blob_sha1_hex("before\n"); let right_oid = git_blob_sha1_hex("after\n"); @@ -196,20 +237,20 @@ async fn delete_then_readd_same_path_becomes_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("cycle.txt"), "before\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let delete = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: cycle.txt\n*** End Patch", ) .await; - tracker.track_delta(&delete); + tracker.track_delta("", &delete); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: cycle.txt\n+after\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let left_oid = git_blob_sha1_hex("before\n"); let right_oid = git_blob_sha1_hex("after\n"); @@ -232,13 +273,13 @@ async fn move_over_existing_destination_without_content_change_deletes_source_on fs::write(dir.path().join("a.txt"), "same\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "same\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let move_overwrite = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n same\n*** End Patch", ) .await; - tracker.track_delta(&move_overwrite); + tracker.track_delta("", &move_overwrite); let left_oid = git_blob_sha1_hex("same\n"); let expected = format!( @@ -261,13 +302,13 @@ async fn move_over_existing_destination_with_content_change_deletes_source_and_u fs::write(dir.path().join("a.txt"), "from\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let move_overwrite = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch", ) .await; - tracker.track_delta(&move_overwrite); + tracker.track_delta("", &move_overwrite); let left_oid_a = git_blob_sha1_hex("from\n"); let left_oid_b = git_blob_sha1_hex("existing\n"); @@ -298,13 +339,13 @@ async fn preserves_committed_change_order_with_delete_then_move_overwrite() { fs::write(dir.path().join("a.txt"), "from\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let ordered_patch = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: b.txt\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch", ) .await; - tracker.track_delta(&ordered_patch); + tracker.track_delta("", &ordered_patch); let left_oid_a = git_blob_sha1_hex("from\n"); let left_oid_b = git_blob_sha1_hex("existing\n"); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 1e2493a807..db301e39b4 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -8,11 +8,18 @@ use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -25,11 +32,14 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; #[cfg(target_os = "linux")] use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::assert_regex_match; +use core_test_support::get_remote_test_env; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -37,11 +47,13 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call_with_args; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use serde_json::json; @@ -1553,6 +1565,162 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_with_remote_and_local_env(&server).await?; + let file_name = "shared-turn-diff.txt"; + let shared_cwd = PathBuf::from(format!( + "/tmp/codex-remote-turn-diff-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let _ = fs::remove_dir_all(shared_cwd.as_path()); + test.fs() + .remove( + &shared_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + fs::create_dir_all(shared_cwd.as_path())?; + test.fs() + .create_directory( + &shared_cwd, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + + let local_patch = format!( + "*** Begin Patch\n*** Environment ID: {LOCAL_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+local\n*** End Patch" + ); + let remote_patch = format!( + "*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+remote\n*** End Patch" + ); + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-local"), + ev_apply_patch_custom_tool_call("call-local", &local_patch), + ev_completed("resp-local"), + ]), + sse(vec![ + ev_response_created("resp-remote"), + ev_apply_patch_custom_tool_call("call-remote", &remote_patch), + ev_completed("resp-remote"), + ]), + sse(vec![ + ev_response_created("resp-done"), + ev_assistant_message("msg-done", "done"), + ev_completed("resp-done"), + ]), + ], + ) + .await; + + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "apply matching patches to local and remote environments".into(), + text_elements: Vec::new(), + }], + environments: Some(vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + ]), + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.clone()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let mut last_diff = None; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnDiff(ev) => { + last_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TurnComplete(_) => true, + _ => false, + }) + .await; + + assert_eq!(fs::read_to_string(shared_cwd.join(file_name))?, "local\n"); + assert_eq!( + test.fs() + .read_file_text(&shared_cwd.join(file_name), /*sandbox*/ None) + .await?, + "remote\n" + ); + let diff = last_diff.expect("expected TurnDiff event"); + assert_eq!( + diff, + r#"diff --git a/local/shared-turn-diff.txt b/local/shared-turn-diff.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..40830374235df1c19661a2901b7ca73cc9499f3d +--- /dev/null ++++ b/local/shared-turn-diff.txt +@@ -0,0 +1 @@ ++local +diff --git a/remote/shared-turn-diff.txt b/remote/shared-turn-diff.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..9c998f7b995a7327177b38a90d1385170df2b94b +--- /dev/null ++++ b/remote/shared-turn-diff.txt +@@ -0,0 +1 @@ ++remote +"# + ); + + let _ = fs::remove_dir_all(shared_cwd.as_path()); + test.fs() + .remove( + &shared_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> { skip_if_no_network!(Ok(())); From 841f057f2d2647441cfd3fa586f3f5d06f89d599 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 14:33:31 -0300 Subject: [PATCH 074/141] fix(tui): avoid doubled blank rows while streaming (#26636) ## Summary During assistant-message streaming, blank markdown lines in the transient active tail were prefixed with two spaces. Ratatui measured those whitespace-only lines as two viewport rows, so list- and table-heavy answers showed doubled vertical gaps while streaming and then visibly compacted when finalized into scrollback. - keep whitespace-only `StreamingAgentTailCell` lines structurally empty while preserving nonblank message prefixes - clear impossible hyperlink metadata when normalizing a blank tail line - add an inline snapshot and height regression proving one blank markdown line occupies one viewport row Related to #26618, but fixes a separate live-tail row-height issue rather than stale committed markdown content. ## How to Test Recommended before/after reproduction: 1. Start the latest Codex build without this change. 2. Submit this exact prompt: > Send 20 different lists: bullets vs numbered, simple vs complex with paragraphs in between items, etc. Intertwine them with some tables and some paragraphs. 3. While the answer streams, observe duplicated vertical gaps around list items and paragraphs. When the answer finishes, observe the spacing compact. 4. Start this branch with `just c` and submit the same prompt. 5. Confirm each intended blank markdown line occupies one terminal row throughout streaming and that the spacing does not compact or jump when the answer finishes. 6. As a focused regression, verify the sections after the first table, especially loose lists with paragraphs between items; those blank rows should remain stable throughout streaming. Targeted tests: - `just test -p codex-tui streaming_agent_tail_blank_line_uses_one_viewport_row` - `just test -p codex-tui history_cell::tests` ## Test Notes - Verified the exact prompt above in a real tmux TUI using latest Codex and this branch as the before/after comparison. - The full `just test -p codex-tui` run completed 2,782 of 2,784 tests successfully. Two unrelated guardian feature-flag tests fail reproducibly in isolation because the expected `OverrideTurnContext` message is absent. - `just argument-comment-lint` is blocked locally by the existing Bazel `compiler-rt` missing-header glob error; the touched Rust diff was inspected manually for opaque positional literals. --- codex-rs/tui/src/history_cell/messages.rs | 16 ++++++++++++++-- codex-rs/tui/src/history_cell/tests.rs | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index ed899f3b2e..787f999cf1 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -423,7 +423,7 @@ impl HistoryCell for StreamingAgentTailCell { fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_hyperlink_lines( + let mut lines = prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -431,7 +431,19 @@ impl HistoryCell for StreamingAgentTailCell { " ".into() }, " ".into(), - ) + ); + for line in &mut lines { + if line + .line + .spans + .iter() + .all(|span| span.content.chars().all(char::is_whitespace)) + { + line.line = Line::default().style(line.line.style); + line.hyperlinks.clear(); + } + } + lines } fn transcript_hyperlink_lines(&self, width: u16) -> Vec { diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 63a04d19d4..ec3ae49f21 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -45,6 +45,24 @@ fn test_cwd() -> PathBuf { std::env::temp_dir() } +#[test] +fn streaming_agent_tail_blank_line_uses_one_viewport_row() { + let cell = StreamingAgentTailCell::new( + vec![ + HyperlinkLine::from("first"), + HyperlinkLine::from(""), + HyperlinkLine::from("second"), + ], + /*is_first_line*/ false, + ); + + let lines = cell.display_lines(/*width*/ 80); + insta::assert_snapshot!(render_lines(&lines).join("\n"), @" first + + second"); + assert_eq!(cell.desired_height(/*width*/ 80), 3); +} + fn stdio_server_config( command: &str, args: Vec<&str>, From 0177231ca055955bd553ac5f57d59f5df9639dd4 Mon Sep 17 00:00:00 2001 From: hefuc-oai Date: Fri, 5 Jun 2026 10:33:56 -0700 Subject: [PATCH 075/141] feat(app-server): add remote control pairing status RPC (#26450) ## What Exposes the pairing status transport as experimental app-server v2 RPC `remoteControl/pairing/status`. - Adds request/response protocol types for exactly one lookup key: `pairingCode` or `manualPairingCode`, returning `{ claimed }`. - Registers the RPC with `global_shared_read("remote-control-pairing")`. - Wires the method through `MessageProcessor` and `RemoteControlRequestProcessor`. - Validates missing/conflicting pairing-code params as invalid requests. - Documents the RPC in `app-server/README.md`. - Adds processor, protocol export, and JSON-RPC integration coverage for both code paths. ## Why This is the app-server surface the desktop app can poll while the QR/manual pairing modal is active. Depends on https://github.com/openai/codex/pull/26449 Related backend change: https://github.com/openai/openai/pull/990244 ## Verification - `cargo test --manifest-path app-server-protocol/Cargo.toml remote_control` - `cargo test --manifest-path app-server/Cargo.toml remote_control` - `cargo fmt --all --check` - `git diff --check` --- codex-rs/app-server-protocol/src/export.rs | 3 + .../src/protocol/common.rs | 19 +++++ codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 5 ++ .../remote_control_processor.rs | 27 +++++++ .../remote_control_processor_tests.rs | 53 +++++++++++++ .../tests/common/test_app_server.rs | 11 +++ .../tests/suite/v2/remote_control.rs | 74 +++++++++++++++++++ 8 files changed, 193 insertions(+) diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 20f923f327..cd6c7e47b6 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2963,11 +2963,14 @@ permissionProfile?: string | null}; let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert!(client_request_json.contains("remoteControl/pairing/start")); + assert!(client_request_json.contains("remoteControl/pairing/status")); assert!(client_request_json.contains("remoteControl/client/list")); assert!(client_request_json.contains("remoteControl/client/revoke")); for schema in [ "RemoteControlPairingStartParams.json", "RemoteControlPairingStartResponse.json", + "RemoteControlPairingStatusParams.json", + "RemoteControlPairingStatusResponse.json", "RemoteControlClientsListParams.json", "RemoteControlClientsListResponse.json", "RemoteControlClientsRevokeParams.json", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a624387e4d..1c996d7cb0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -849,6 +849,12 @@ client_request_definitions! { serialization: global("remote-control-pairing"), response: v2::RemoteControlPairingStartResponse, }, + #[experimental("remoteControl/pairing/status")] + RemoteControlPairingStatus => "remoteControl/pairing/status" { + params: v2::RemoteControlPairingStatusParams, + serialization: global_shared_read("remote-control-pairing"), + response: v2::RemoteControlPairingStatusResponse, + }, #[experimental("remoteControl/client/list")] RemoteControlClientsList => "remoteControl/client/list" { params: v2::RemoteControlClientsListParams, @@ -2014,6 +2020,19 @@ mod tests { "remote-control-pairing" )) ); + let remote_control_pairing_status = ClientRequest::RemoteControlPairingStatus { + request_id: request_id(), + params: v2::RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }, + }; + assert_eq!( + remote_control_pairing_status.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "remote-control-pairing" + )) + ); let remote_control_clients_list = ClientRequest::RemoteControlClientsList { request_id: request_id(), params: v2::RemoteControlClientsListParams::default(), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b7fc6b82a3..4e06dc16e3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -211,6 +211,7 @@ Example with notification opt-out: - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. +- `remoteControl/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`. - `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 07bc06218e..76f39663a0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -921,6 +921,11 @@ impl MessageProcessor { .pairing_start(params, app_server_client_name.as_deref()) .await .map(|response| Some(response.into())), + ClientRequest::RemoteControlPairingStatus { params, .. } => self + .remote_control_processor + .pairing_status(params) + .await + .map(|response| Some(response.into())), ClientRequest::RemoteControlClientsList { params, .. } => self .remote_control_processor .clients_list(params) diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 22223687bc..31b5fd6b74 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -11,6 +11,8 @@ use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use std::io; @@ -60,6 +62,17 @@ impl RemoteControlRequestProcessor { .map_err(map_pairing_start_error) } + pub(crate) async fn pairing_status( + &self, + params: RemoteControlPairingStatusParams, + ) -> Result { + validate_pairing_status_params(¶ms)?; + self.handle()? + .pairing_status(params) + .await + .map_err(map_pairing_start_error) + } + pub(crate) async fn clients_list( &self, params: RemoteControlClientsListParams, @@ -99,6 +112,20 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { } } +fn validate_pairing_status_params( + params: &RemoteControlPairingStatusParams, +) -> Result<(), JSONRPCErrorError> { + match (¶ms.pairing_code, ¶ms.manual_pairing_code) { + (Some(_), None) | (None, Some(_)) => Ok(()), + (Some(_), Some(_)) => Err(invalid_request( + "remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both", + )), + (None, None) => Err(invalid_request( + "remoteControl/pairing/status requires pairingCode or manualPairingCode", + )), + } +} + fn map_client_management_error(err: io::Error) -> JSONRPCErrorError { match err.kind() { io::ErrorKind::InvalidInput diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index e336491cd3..36c60b913e 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -23,6 +23,59 @@ async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable ); } +#[tokio::test] +async fn pairing_status_returns_internal_error_when_remote_control_is_unavailable() { + let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) + .pairing_status(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect_err("missing remote control should fail pairing status"); + + assert_eq!( + err, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control is unavailable for this app-server".to_string(), + } + ); +} + +#[test] +fn pairing_status_rejects_missing_pairing_codes() { + assert_eq!( + validate_pairing_status_params(&RemoteControlPairingStatusParams { + pairing_code: None, + manual_pairing_code: None, + }), + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "remoteControl/pairing/status requires pairingCode or manualPairingCode" + .to_string(), + }) + ); +} + +#[test] +fn pairing_status_rejects_conflicting_pairing_codes() { + assert_eq!( + validate_pairing_status_params(&RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }), + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: + "remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both" + .to_string(), + }) + ); +} + #[test] fn pairing_start_maps_invalid_input_to_invalid_request() { assert_eq!( diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index ec4b30d92f..646de6ad21 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -70,6 +70,7 @@ use codex_app_server_protocol::ProcessWriteStdinParams; use codex_app_server_protocol::RemoteControlClientsListParams; use codex_app_server_protocol::RemoteControlClientsRevokeParams; use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; @@ -656,6 +657,16 @@ impl TestAppServer { .await } + /// Send a `remoteControl/pairing/status` JSON-RPC request. + pub async fn send_remote_control_pairing_status_request( + &mut self, + params: RemoteControlPairingStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/pairing/status", params) + .await + } + /// Send a `remoteControl/client/list` JSON-RPC request. pub async fn send_remote_control_clients_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index c48ffda0f6..0229b0e5af 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -19,12 +19,15 @@ use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::net::TcpListener; @@ -190,6 +193,44 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> expires_at: 33_336_362_096, } ); + + let request_id = mcp + .send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStatusResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStatusResponse { claimed: true } + ); + + let request_id = mcp + .send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: None, + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStatusResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStatusResponse { claimed: true } + ); Ok(()) } @@ -429,6 +470,25 @@ impl PairingRemoteControlBackend { }), ) .await?; + for expected_body in [ + serde_json::json!({ "pairing_code": "pairing-code" }), + serde_json::json!({ "manual_pairing_code": "ABCD-EFGH" }), + ] { + let status_http_request = read_http_request(&listener).await?; + assert_eq!( + status_http_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&status_http_request.body)?, + expected_body + ); + respond_with_json( + status_http_request.reader.into_inner(), + serde_json::json!({ "claimed": true }), + ) + .await?; + } std::future::pending::<()>().await; Ok::<(), anyhow::Error>(()) } @@ -476,6 +536,7 @@ impl Drop for ClientManagementRemoteControlBackend { struct HttpRequest { request_line: String, + body: String, reader: BufReader, } @@ -508,16 +569,29 @@ async fn read_http_request(listener: &TcpListener) -> Result { let mut request_line = String::new(); reader.read_line(&mut request_line).await?; + let mut content_length = 0; loop { let mut line = String::new(); reader.read_line(&mut line).await?; if line == "\r\n" { break; } + if let Some(value) = line + .trim_end() + .strip_prefix("content-length:") + .or_else(|| line.trim_end().strip_prefix("Content-Length:")) + { + content_length = value.trim().parse::()?; + } + } + let mut body = vec![0; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).await?; } Ok(HttpRequest { request_line: request_line.trim_end().to_string(), + body: String::from_utf8(body)?, reader, }) } From 78eba34b4148ec14493684ffc3a866415cbbbbd1 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 5 Jun 2026 10:36:14 -0700 Subject: [PATCH 076/141] Clean up Rust release workflow (#26335) ## Why PR #26252 moved macOS release signing into the tag-triggered `rust-release` workflow through the protected `codesigning` environment and Azure Key Vault. That leaves the old manual unsigned-build / signed-promotion handoff as dead compatibility scaffolding: it makes the release DAG harder to reason about and keeps paths around that the current release process no longer intends to operate. ## What changed - Remove the manual `workflow_dispatch` inputs and validation for `build_unsigned`, `promote_signed`, and the deprecated `sign_macos` flag. - Drop the `stage-signed-macos` job and the promotion-specific artifact download, re-upload, pruning, and cleanup logic. - Make tag-pushed releases always follow the signed release path: build, sign, package, finalize, publish, and then run downstream release jobs from `release` success. - Remove stale `SIGN_MACOS` / `sign_macos` conditions and outputs, including downstream gates for npm, DotSlash, WinGet, dev website deploy, and `latest-alpha-cli` branch updates. ## Verification - `ruby -e 'require "yaml"; YAML.load_file(ARGV.fetch(0)); puts "yaml ok"' .github/workflows/rust-release.yml` - `git diff --check` - `rg -n "workflow_dispatch|inputs\\.|release_mode|build_unsigned|SIGN_MACOS|outputs\\.sign_macos|sign_macos\\b" .github/workflows/rust-release.yml` returned no matches --- .github/workflows/rust-release.yml | 556 +---------------------------- 1 file changed, 13 insertions(+), 543 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 03a0cc57ff..f95d1216f8 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -7,46 +7,12 @@ # # Tag releases sign macOS binaries and DMGs through the protected `codesigning` # GitHub environment and Azure Key Vault before final verification on macOS. -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: push: tags: - "rust-v*.*.*" - workflow_dispatch: - inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." - required: false - type: choice - default: build_unsigned - options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string concurrency: group: ${{ github.workflow }} @@ -62,61 +28,11 @@ jobs: - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - - # All release modes must run from a tag. + # Release runs must come from a tag. [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag ref"; exit 1; } @@ -135,7 +51,6 @@ jobs: echo "::endgroup::" build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} @@ -514,7 +429,6 @@ jobs: codex-rs/dist/${{ matrix.target }}/* sign-macos-binaries: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: build name: Sign macOS binaries - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ubuntu-latest @@ -634,7 +548,6 @@ jobs: if-no-files-found: warn package-macos: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: sign-macos-binaries name: Package macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: macos-15-xlarge @@ -824,7 +737,6 @@ jobs: if-no-files-found: error sign-macos-dmg: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: package-macos name: Sign macOS DMG - ${{ matrix.target }} runs-on: ubuntu-latest @@ -921,7 +833,6 @@ jobs: if-no-files-found: warn finalize-macos: - if: ${{ github.event_name != 'workflow_dispatch' }} needs: - package-macos - sign-macos-dmg @@ -1083,253 +994,12 @@ jobs: path: codex-rs/dist/${{ matrix.target }}/* if-no-files-found: error - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download signed macOS handoff - shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} - run: | - set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi - fi - - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" - - - name: Stage signed macOS artifacts - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print - exit 1 - fi - - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" - fi - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml secrets: inherit argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -1337,7 +1007,6 @@ jobs: publish: true zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml @@ -1347,7 +1016,6 @@ jobs: - tag-check - build - finalize-macos - - stage-signed-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets @@ -1355,52 +1023,20 @@ jobs: ${{ always() && needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.finalize-macos.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'build_unsigned' && - needs.finalize-macos.result == 'skipped' - ) || - ( - github.event_name != 'workflow_dispatch' && - needs.finalize-macos.result == 'success' - ) - ) && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) + needs.build.result == 'success' && + needs.finalize-macos.result == 'success' && + needs.build-windows.result == 'success' && + needs.argument-comment-lint-release-assets.result == 'success' && + needs.zsh-release-assets.result == 'success' }} name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} @@ -1410,12 +1046,6 @@ jobs: with: persist-credentials: false - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" - - name: Generate release notes from tag commit message id: release_notes shell: bash @@ -1440,121 +1070,9 @@ jobs: with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - - name: Delete entries from dist/ that should not go in the release run: | rm -rf dist/windows-binaries* @@ -1564,9 +1082,7 @@ jobs: rm -rf dist/*-apple-darwin*-signed-dmg rm -rf dist/*-apple-darwin*-binary-signing-verification rm -rf dist/*-apple-darwin*-dmg-signing-verification - if [[ "${SIGN_MACOS}" == "true" ]]; then - rm -rf dist/*-apple-darwin*-unsigned - fi + rm -rf dist/*-apple-darwin*-unsigned # cargo-timing.html appears under multiple target-specific directories. # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. @@ -1618,12 +1134,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1636,23 +1146,19 @@ jobs: fi - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} @@ -1666,7 +1172,6 @@ jobs: --package codex-sdk - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} run: | cp scripts/install/install.sh dist/install.sh cp scripts/install/install.ps1 dist/install.ps1 @@ -1679,55 +1184,26 @@ jobs: body_path: ${{ steps.release_notes.outputs.path }} files: dist/** overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + make_latest: ${{ !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-zsh-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -1739,9 +1215,6 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. if: >- ${{ !cancelled() && @@ -1902,13 +1375,12 @@ jobs: deploy-dev-website: name: Trigger developers.openai.com deploy needs: release - # Only trigger the deploy for a stable signed release. + # Only trigger the deploy for a stable release. # The deploy updates developers.openai.com with the new config schema json file. if: >- ${{ !cancelled() && needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} runs-on: ubuntu-latest @@ -1938,7 +1410,6 @@ jobs: ${{ !cancelled() && needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} # This job only invokes a GitHub Action to open/update the winget-pkgs PR; @@ -1966,8 +1437,7 @@ jobs: if: >- ${{ !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' + needs.release.result == 'success' }} permissions: contents: write From a8c95309111f1bb8b4864102a306d7e60a729ad0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 10:37:38 -0700 Subject: [PATCH 077/141] [1 of 2] Align goal extension with core behavior (#26547) ## Stack 1. [#26547](https://github.com/openai/codex/pull/26547) - [1 of 2] Align goal extension with core behavior 2. [#26548](https://github.com/openai/codex/pull/26548) - [2 of 2] Move goal runtime to extension ## Why The goal runtime is moving out of `codex-core` and into `codex-goal-extension`. This first PR brings the extension back in line with the current core behavior before the follow-up PR switches app-server sessions over to the extension, so that review can focus on ownership and wiring rather than hidden behavior drift. ## What Changed - Updates the extension `create_goal` and `update_goal` tool schemas/descriptions to match the current core wording for explicit token budgets, blocked-goal audits, resumed blocked goals, and system-owned budget/usage-limit transitions. - Marks `codex-goal-extension` as the live `/goal` extension crate rather than an unwired sketch. - Looks up the live thread before reading goal state for idle continuation, so continuation setup exits early when no live thread can accept the automatic turn. --- codex-rs/ext/goal/src/api.rs | 30 +++++++++++++++++++++++++++-- codex-rs/ext/goal/src/lib.rs | 6 +----- codex-rs/ext/goal/src/runtime.rs | 33 +++++++++++++++++++++++--------- codex-rs/ext/goal/src/spec.rs | 14 +++++++++----- 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index 0e23c295a2..5123e6a5ce 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -124,7 +124,19 @@ impl GoalService { .map_err(GoalServiceError::InvalidRequest)?; } - if let Some(runtime) = self.runtime_for_thread(thread_id) + let runtime = self.runtime_for_thread(thread_id); + // Hold this through the prepare/write window so idle continuation cannot + // launch from goal state that this external mutation is about to change. + let _goal_state_permit = match runtime.as_ref() { + Some(runtime) => Some( + runtime + .goal_state_permit() + .await + .map_err(GoalServiceError::Internal)?, + ), + None => None, + }; + if let Some(runtime) = runtime.as_ref() && let Err(err) = runtime.prepare_external_goal_mutation().await { tracing::warn!("failed to prepare external goal mutation: {err}"); @@ -229,7 +241,19 @@ impl GoalService { state_db: &codex_state::StateRuntime, thread_id: ThreadId, ) -> Result { - if let Some(runtime) = self.runtime_for_thread(thread_id) + let runtime = self.runtime_for_thread(thread_id); + // Hold this through the prepare/write window so idle continuation cannot + // launch from goal state that this external mutation is about to change. + let goal_state_permit = match runtime.as_ref() { + Some(runtime) => Some( + runtime + .goal_state_permit() + .await + .map_err(GoalServiceError::Internal)?, + ), + None => None, + }; + if let Some(runtime) = runtime.as_ref() && let Err(err) = runtime.prepare_external_goal_mutation().await { tracing::warn!("failed to prepare external goal mutation: {err}"); @@ -242,6 +266,8 @@ impl GoalService { .map_err(|err| { GoalServiceError::Internal(format!("failed to clear thread goal: {err}")) })?; + drop(goal_state_permit); + drop(runtime); if cleared && let Some(runtime) = self.runtime_for_thread(thread_id) diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs index 8433e1f7df..bb640c6ce8 100644 --- a/codex-rs/ext/goal/src/lib.rs +++ b/codex-rs/ext/goal/src/lib.rs @@ -1,8 +1,4 @@ -//! Extension crate sketch for the `/goal` feature. -//! -//! This crate is intentionally not wired into the host yet. It contains the -//! goal tool specs, extension registration shape, and the parts of runtime -//! accounting that can be represented with today's extension API. +//! Extension crate for the `/goal` feature. mod accounting; mod api; diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index b58d1244e6..9818282cae 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -15,6 +15,8 @@ use crate::metrics::GoalMetrics; use crate::steering::continuation_steering_item; use crate::steering::objective_updated_steering_item; use crate::tool::protocol_goal_from_state; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; #[derive(Clone)] pub struct GoalRuntimeHandle { @@ -35,6 +37,7 @@ struct GoalRuntimeInner { accounting_state: Arc, enabled: AtomicBool, tools_available_for_thread: bool, + goal_state_lock: Semaphore, } pub(crate) struct AccountedGoalProgress { @@ -85,6 +88,7 @@ impl GoalRuntimeHandle { accounting_state, enabled: AtomicBool::new(config.enabled), tools_available_for_thread: config.tools_available_for_thread, + goal_state_lock: Semaphore::new(/*permits*/ 1), }), } } @@ -109,6 +113,14 @@ impl GoalRuntimeHandle { Arc::clone(&self.inner.accounting_state) } + pub(crate) async fn goal_state_permit(&self) -> Result, String> { + self.inner + .goal_state_lock + .acquire() + .await + .map_err(|err| err.to_string()) + } + pub async fn prepare_external_goal_mutation(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); @@ -280,6 +292,18 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); return Ok(()); } + // Hold this through the read/start window so external set/clear cannot + // change the goal after we read it but before the continuation launches. + let _goal_state_permit = self.goal_state_permit().await?; + + let Some(thread_manager) = self.inner.thread_manager.upgrade() else { + tracing::debug!("skipping goal continuation because thread manager is unavailable"); + return Ok(()); + }; + let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else { + tracing::debug!("skipping goal continuation because live thread is unavailable"); + return Ok(()); + }; let Some(goal) = self .inner @@ -296,16 +320,7 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); return Ok(()); } - let item = continuation_steering_item(&protocol_goal_from_state(goal)); - let Some(thread_manager) = self.inner.thread_manager.upgrade() else { - tracing::debug!("skipping goal continuation because thread manager is unavailable"); - return Ok(()); - }; - let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else { - tracing::debug!("skipping goal continuation because live thread is unavailable"); - return Ok(()); - }; if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await { let reason = err.reason(); diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs index 70e89e4fbb..2c92c03848 100644 --- a/codex-rs/ext/goal/src/spec.rs +++ b/codex-rs/ext/goal/src/spec.rs @@ -34,7 +34,8 @@ pub fn create_create_goal_tool() -> ToolSpec { ( "token_budget".to_string(), JsonSchema::integer(Some( - "Optional positive token budget for the new active goal.".to_string(), + "Positive token budget for the new goal. Omit unless explicitly requested." + .to_string(), )), ), ]); @@ -62,7 +63,7 @@ pub fn create_update_goal_tool() -> ToolSpec { JsonSchema::string_enum( vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains. Set to blocked only when the goal cannot currently proceed without a user decision, missing dependency, or external unblock." + "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." .to_string(), ), ), @@ -71,11 +72,14 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved or blocked. +Use this tool only to mark the goal achieved or genuinely blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. -Set status to `blocked` only when the goal cannot currently proceed until something external changes. +Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. +If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. +Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. +Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# .to_string(), strict: false, From 713192381b74f09f4b92f3ed410da067461d5cd0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 15:05:46 -0300 Subject: [PATCH 078/141] fix(tui): Windows composer background (#26181) ## Why On Windows, the TUI could not shade the composer against the terminal background because `terminal_palette::default_colors()` always fell back to `None`. That preserved safety, but it also meant terminals that do support OSC 10/11 default color replies had no path to report their real background color. This keeps the existing fallback behavior for unsupported terminals while allowing capable Windows terminals to report their default foreground/background colors during startup. | Before | After | |---|---| | win-before | win-after | ## What Changed - Moved the OSC 10/11 default color parser in `tui/src/terminal_probe.rs` out of the Unix-only implementation so it can be reused by Windows. - Added a Windows-only bounded OSC 10/11 probe using raw console handles and the existing `windows-sys` dependency. - Added Windows palette caching in `tui/src/terminal_palette.rs` so startup probe results, including `None`, are reused instead of probing again later. - Wired the Windows color probe into TUI startup after the existing non-Unix crossterm cursor and keyboard checks. - Added parser coverage for malformed, partial, and noisy OSC color replies. If the probe fails, times out, receives only one color, or receives malformed data, the cache stores `None` and the composer keeps the current behavior. ## How to Test 1. On Windows, start Codex in a terminal that supports OSC 10/11 default color replies. 2. Open the TUI composer. 3. Confirm the composer/status area is painted using the terminal's reported default background, instead of leaving the background unshaded. 4. Start Codex in a terminal that does not answer OSC 10/11, or otherwise blocks terminal color replies. 5. Confirm startup still succeeds and the composer uses the existing fallback behavior. Targeted tests: - `CARGO_TARGET_DIR=/private/tmp/codex-windows-osc-default-colors-target just test -p codex-tui terminal_probe` Additional local verification: - `CARGO_TARGET_DIR=/private/tmp/codex-windows-osc-default-colors-target just test -p codex-tui` was run; 2774 tests passed, and two unrelated Guardian feature-flag tests failed reproducibly when isolated. - `just argument-comment-lint` was attempted but blocked by the local Bazel/LLVM `include/sanitizer/*.h` empty glob issue. Touched Rust literal callsites were inspected manually. - `cargo check -p codex-tui --target x86_64-pc-windows-msvc` was attempted after installing the target, but local macOS cross-checking is blocked by missing Windows C SDK headers in native dependencies (`ring`/`aws-lc-sys`). --------- Co-authored-by: Kevin Bond --- codex-rs/tui/src/terminal_palette.rs | 187 +++++++++- codex-rs/tui/src/terminal_probe.rs | 536 +++++++++++++++++++++------ codex-rs/tui/src/tui.rs | 29 +- 3 files changed, 616 insertions(+), 136 deletions(-) diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 53c68d96e7..1b539f94cb 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,4 +1,6 @@ use crate::color::perceptual_distance; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use ratatui::style::Color; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -30,19 +32,49 @@ pub fn indexed_color(index: u8) -> Color { /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { - let color_level = stdout_color_level(); - if color_level == StdoutColorLevel::TrueColor { - rgb_color(target) - } else if color_level == StdoutColorLevel::Ansi256 - && let Some((i, _)) = xterm_fixed_colors().min_by(|(_, a), (_, b)| { - perceptual_distance(*a, target) - .partial_cmp(&perceptual_distance(*b, target)) - .unwrap_or(std::cmp::Ordering::Equal) - }) + best_color_for_color_level(target, effective_stdout_color_level()) +} + +fn effective_stdout_color_level() -> StdoutColorLevel { + stdout_color_level_for_terminal( + stdout_color_level(), + terminal_info().name, + std::env::var_os("WT_SESSION").is_some(), + std::env::var_os("FORCE_COLOR").is_some(), + ) +} + +fn stdout_color_level_for_terminal( + stdout_level: StdoutColorLevel, + terminal_name: TerminalName, + has_wt_session: bool, + has_force_color_override: bool, +) -> StdoutColorLevel { + if has_wt_session && !has_force_color_override { + return StdoutColorLevel::TrueColor; + } + + if stdout_level == StdoutColorLevel::Ansi16 + && terminal_name == TerminalName::WindowsTerminal + && !has_force_color_override { - indexed_color(i as u8) + StdoutColorLevel::TrueColor } else { - Color::default() + stdout_level + } +} + +fn best_color_for_color_level(target: (u8, u8, u8), color_level: StdoutColorLevel) -> Color { + match color_level { + StdoutColorLevel::TrueColor => rgb_color(target), + StdoutColorLevel::Ansi256 => xterm_fixed_colors() + .min_by(|(_, a), (_, b)| { + perceptual_distance(*a, target) + .partial_cmp(&perceptual_distance(*b, target)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map_or_else(Color::default, |(i, _)| indexed_color(i as u8)), + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => Color::default(), } } @@ -68,7 +100,7 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } -#[cfg(unix)] +#[cfg(any(unix, windows))] pub(crate) fn set_default_colors_from_startup_probe( colors: Option, ) { @@ -177,7 +209,73 @@ mod imp { } } -#[cfg(not(all(unix, not(test))))] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use std::sync::Mutex; + use std::sync::OnceLock; + + struct Cache { + attempted: bool, + value: Option, + } + + impl Default for Cache { + fn default() -> Self { + Self { + attempted: false, + value: None, + } + } + } + + impl Cache { + fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option) -> Option { + if !self.attempted { + self.value = init(); + self.attempted = true; + } + self.value + } + } + + fn default_colors_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Cache::default())) + } + + pub(super) fn default_colors() -> Option { + let cache = default_colors_cache(); + let mut cache = cache.lock().ok()?; + cache.get_or_init_with(query_default_colors) + } + + pub(super) fn set_default_colors_from_startup_probe( + colors: Option, + ) { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.value = colors.map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }); + cache.attempted = true; + } + } + + pub(super) fn requery_default_colors() {} + + fn query_default_colors() -> Option { + crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) + .ok() + .flatten() + .map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }) + } +} + +#[cfg(not(any(all(unix, not(test)), windows)))] mod imp { use super::DefaultColors; @@ -185,7 +283,7 @@ mod imp { None } - #[cfg(unix)] + #[cfg(any(unix, windows))] pub(super) fn set_default_colors_from_startup_probe( _colors: Option, ) { @@ -461,3 +559,64 @@ pub const XTERM_COLORS: [(u8, u8, u8); 256] = [ (228, 228, 228), // 254 Grey89 (238, 238, 238), // 255 Grey93 ]; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn best_color_uses_truecolor_without_quantization() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::TrueColor), + rgb_color((12, 34, 56)) + ); + } + + #[test] + fn best_color_resets_for_ansi16() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::Ansi16), + Color::Reset + ); + } + + #[test] + fn windows_terminal_wt_session_promotes_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::Unknown, + /*has_wt_session*/ true, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn windows_terminal_name_promotes_ansi16_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ false, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn force_color_keeps_reported_stdout_level() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ true, + /*has_force_color_override*/ true, + ), + StdoutColorLevel::Ansi16 + ); + } +} diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index d9927ffa20..bea195083b 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -12,9 +12,25 @@ //! startup. A future input-preservation layer would need to replay unrelated bytes through the same //! parser that normal TUI input uses. +use std::time::Duration; + +/// Default wall-clock budget for each startup probe group. +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); + +/// Default terminal foreground and background colors reported by OSC 10 and OSC 11. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) struct DefaultColors { + /// Default foreground color as an 8-bit RGB tuple. + pub(crate) fg: (u8, u8, u8), + /// Default background color as an 8-bit RGB tuple. + pub(crate) bg: (u8, u8, u8), +} + #[cfg(unix)] #[cfg_attr(test, allow(dead_code))] mod imp { + use super::DefaultColors; + use super::parse_default_colors; use std::fs::File; use std::fs::OpenOptions; use std::io; @@ -27,18 +43,6 @@ mod imp { use crossterm::event::KeyboardEnhancementFlags; use ratatui::layout::Position; - /// Default wall-clock budget for each startup probe group. - pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); - - /// Default terminal foreground and background colors reported by OSC 10 and OSC 11. - #[derive(Debug, Clone, Copy, Eq, PartialEq)] - pub(crate) struct DefaultColors { - /// Default foreground color as an 8-bit RGB tuple. - pub(crate) fg: (u8, u8, u8), - /// Default background color as an 8-bit RGB tuple. - pub(crate) bg: (u8, u8, u8), - } - /// Results from the TUI's one-shot startup terminal probe. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) struct StartupProbe { @@ -389,60 +393,6 @@ mod imp { None } - fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { - let prefix = format!("\x1B]{slot};"); - let start = find_subslice(buffer, prefix.as_bytes())?; - let payload_start = start + prefix.len(); - let rest = &buffer[payload_start..]; - let (payload_end, _terminator_len) = osc_payload_end(rest)?; - let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; - parse_osc_rgb(payload) - } - - fn parse_default_colors(buffer: &[u8]) -> Option { - let fg = parse_osc_color(buffer, /*slot*/ 10)?; - let bg = parse_osc_color(buffer, /*slot*/ 11)?; - Some(DefaultColors { fg, bg }) - } - - fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { - let mut idx = 0; - while idx < buffer.len() { - match buffer[idx] { - 0x07 => return Some((idx, 1)), - 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), - _ => idx += 1, - } - } - None - } - - fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { - let (prefix, values) = payload.trim().split_once(':')?; - if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { - return None; - } - - let mut parts = values.split('/'); - let r = parse_osc_component(parts.next()?)?; - let g = parse_osc_component(parts.next()?)?; - let b = parse_osc_component(parts.next()?)?; - if prefix.eq_ignore_ascii_case("rgba") { - parse_osc_component(parts.next()?)?; - } - parts.next().is_none().then_some((r, g, b)) - } - - fn parse_osc_component(component: &str) -> Option { - match component.len() { - 2 => u8::from_str_radix(component, 16).ok(), - 4 => u16::from_str_radix(component, 16) - .ok() - .map(|value| (value / 257) as u8), - _ => None, - } - } - /// Parser state for the keyboard enhancement probe. /// /// `UnsupportedFallback` records that a primary-device-attributes response arrived without @@ -517,12 +467,6 @@ mod imp { None } - fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) - } - fn find_all_subslices<'a>( haystack: &'a [u8], needle: &'a [u8], @@ -550,53 +494,6 @@ mod imp { ); } - #[test] - fn parses_osc_colors_with_bel_and_st() { - assert_eq!( - parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), - Some((255, 127, 0)) - ); - assert_eq!( - parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), - Some((0, 128, 255)) - ); - } - - #[test] - fn parses_two_and_four_digit_color_components() { - assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); - assert_eq!( - parse_osc_rgb("rgba:ffff/8000/0000/ffff"), - Some((255, 127, 0)) - ); - } - - #[test] - fn parses_default_colors_from_one_buffer() { - assert_eq!( - parse_default_colors( - b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors( - b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), - None - ); - } - #[test] fn parses_keyboard_enhancement_flags_and_pda_fallback() { assert_eq!( @@ -655,5 +552,404 @@ mod imp { } } -#[cfg(unix)] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use super::parse_default_colors; + use std::io; + use std::io::ErrorKind; + use std::time::Duration; + use std::time::Instant; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::Foundation::WAIT_TIMEOUT; + use windows_sys::Win32::Storage::FileSystem::ReadFile; + use windows_sys::Win32::Storage::FileSystem::WriteFile; + use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + + /// Queries OSC 10 and OSC 11 default colors under one shared deadline. + /// + /// The Windows path uses raw console handles because crossterm's public color query helper is + /// currently Unix-only. Failures and missing responses are reported as `Ok(None)` by callers so + /// terminals without OSC 10/11 support keep the existing conservative palette fallback. + pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { + return Ok(None); + }; + + if let Ok(input) = std_handle(STD_INPUT_HANDLE) + && let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout) + { + return Ok(Some(colors)); + } + + Ok(query_console_default_colors(output).ok().flatten()) + } + + fn query_osc_default_colors( + input: HANDLE, + output: HANDLE, + timeout: Duration, + ) -> io::Result> { + let _vt_input = VirtualTerminalInputMode::enable(input)?; + write_all(output, b"\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; + read_until(input, timeout, parse_default_colors) + } + + fn query_console_default_colors(output: HANDLE) -> io::Result> { + let mut info = unsafe { std::mem::zeroed::() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(decode_console_default_colors( + info.wAttributes, + &info.ColorTable, + ))) + } + + fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { + let fg_index = (attributes & 0x0f) as usize; + let bg_index = ((attributes >> 4) & 0x0f) as usize; + // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the + // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. + DefaultColors { + fg: decode_color_ref(color_table[fg_index]), + bg: decode_color_ref(color_table[bg_index]), + } + } + + fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { + ( + (color_ref & 0xff) as u8, + ((color_ref >> 8) & 0xff) as u8, + ((color_ref >> 16) & 0xff) as u8, + ) + } + + fn std_handle(kind: u32) -> io::Result { + let handle = unsafe { GetStdHandle(kind) }; + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) + } + + struct VirtualTerminalInputMode { + handle: HANDLE, + original_mode: u32, + } + + impl VirtualTerminalInputMode { + fn enable(handle: HANDLE) -> io::Result { + let mut original_mode = 0; + if unsafe { GetConsoleMode(handle, &mut original_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + let requested_mode = original_mode | ENABLE_VIRTUAL_TERMINAL_INPUT; + if unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + handle, + original_mode, + }) + } + } + + impl Drop for VirtualTerminalInputMode { + fn drop(&mut self) { + unsafe { + SetConsoleMode(self.handle, self.original_mode); + } + } + } + + fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut written = 0; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len().min(u32::MAX as usize) as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + bytes = &bytes[written as usize..]; + } + Ok(()) + } + + fn read_until( + handle: HANDLE, + timeout: Duration, + mut parse: impl FnMut(&[u8]) -> Option, + ) -> io::Result> { + let deadline = Instant::now() + timeout; + let mut buffer = Vec::new(); + loop { + if let Some(value) = parse(&buffer) { + return Ok(Some(value)); + } + + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let timeout_ms = deadline + .saturating_duration_since(now) + .as_millis() + .min(u32::MAX as u128) as u32; + match unsafe { WaitForSingleObject(handle, timeout_ms) } { + WAIT_OBJECT_0 => read_once(handle, &mut buffer)?, + WAIT_TIMEOUT => return Ok(None), + _ => return Err(io::Error::last_os_error()), + } + } + } + + fn read_once(handle: HANDLE, buffer: &mut Vec) -> io::Result<()> { + let mut chunk = [0_u8; 256]; + let mut read = 0; + let ok = unsafe { + ReadFile( + handle, + chunk.as_mut_ptr().cast(), + chunk.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + buffer.extend_from_slice(&chunk[..read as usize]); + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use pretty_assertions::assert_eq; + use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; + + fn color_table() -> [u32; 16] { + [ + 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, + 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, + 0x00ffff00, 0x00ffffff, + ] + } + + #[test] + fn decodes_console_color_attribute_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x21, &color_table()), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + + #[test] + fn decodes_console_color_intensity_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), + DefaultColors { + fg: (255, 0, 0), + bg: (0, 255, 255), + } + ); + } + + #[test] + fn decodes_console_color_ref_byte_order() { + let mut colors = color_table(); + colors[3] = 0x00112233; + colors[4] = 0x00aabbcc; + + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x43, &colors), + DefaultColors { + fg: (0x33, 0x22, 0x11), + bg: (0xcc, 0xbb, 0xaa), + } + ); + } + + #[test] + fn ignores_reverse_video_when_decoding_default_colors() { + assert_eq!( + decode_console_default_colors( + /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, + &color_table(), + ), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + } +} + +fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { + let prefix = format!("\x1B]{slot};"); + let start = find_subslice(buffer, prefix.as_bytes())?; + let payload_start = start + prefix.len(); + let rest = &buffer[payload_start..]; + let (payload_end, _terminator_len) = osc_payload_end(rest)?; + let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; + parse_osc_rgb(payload) +} + +fn parse_default_colors(buffer: &[u8]) -> Option { + let fg = parse_osc_color(buffer, /*slot*/ 10)?; + let bg = parse_osc_color(buffer, /*slot*/ 11)?; + Some(DefaultColors { fg, bg }) +} + +fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { + let mut idx = 0; + while idx < buffer.len() { + match buffer[idx] { + 0x07 => return Some((idx, 1)), + 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), + _ => idx += 1, + } + } + None +} + +fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { + let (prefix, values) = payload.trim().split_once(':')?; + if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { + return None; + } + + let mut parts = values.split('/'); + let r = parse_osc_component(parts.next()?)?; + let g = parse_osc_component(parts.next()?)?; + let b = parse_osc_component(parts.next()?)?; + if prefix.eq_ignore_ascii_case("rgba") { + parse_osc_component(parts.next()?)?; + } + parts.next().is_none().then_some((r, g, b)) +} + +fn parse_osc_component(component: &str) -> Option { + match component.len() { + 2 => u8::from_str_radix(component, 16).ok(), + 4 => u16::from_str_radix(component, 16) + .ok() + .map(|value| (value / 257) as u8), + _ => None, + } +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(any(unix, windows))] pub(crate) use imp::*; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parses_osc_colors_with_bel_and_st() { + assert_eq!( + parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), + Some((255, 127, 0)) + ); + assert_eq!( + parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), + Some((0, 128, 255)) + ); + } + + #[test] + fn parses_two_and_four_digit_color_components() { + assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); + assert_eq!( + parse_osc_rgb("rgba:ffff/8000/0000/ffff"), + Some((255, 127, 0)) + ); + } + + #[test] + fn parses_default_colors_from_one_buffer() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + None + ); + } + + #[test] + fn ignores_malformed_or_partial_default_color_responses() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:nope\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:11/11/11/11\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111"), + None + ); + } + + #[test] + fn parses_default_colors_with_unrelated_bytes() { + assert_eq!( + parse_default_colors( + b"typed\x1B]10;rgb:eeee/eeee/eeee\x1B\\noise\x1B]11;rgb:1111/1111/1111\x07" + ), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17), + }) + ); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b9055f5d6b..681e0cb397 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -438,6 +438,9 @@ pub(crate) fn init() -> Result { let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); + #[cfg(windows)] + probe_windows_default_colors(); + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?; Ok(InitializedTerminal { @@ -457,11 +460,33 @@ fn cursor_position_with_crossterm(backend: &mut CrosstermBackend) -> Pos #[cfg(not(unix))] fn detect_keyboard_enhancement_supported() -> bool { - // Non-Unix startup keeps the existing crossterm path because the bounded probe implementation - // relies on Unix file descriptors and `/dev/tty` semantics. + // Non-Unix startup keeps the existing crossterm keyboard probe path because it already knows + // how to interpret platform-specific event sources. supports_keyboard_enhancement().unwrap_or(/*default*/ false) } +#[cfg(windows)] +fn probe_windows_default_colors() { + let started_at = std::time::Instant::now(); + match crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(colors) => { + tracing::info!( + duration_ms = %started_at.elapsed().as_millis(), + default_colors = colors.is_some(), + "terminal default color probe completed" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(colors); + } + Err(err) => { + tracing::warn!( + duration_ms = %started_at.elapsed().as_millis(), + "terminal default color probe failed: {err}" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(/*colors*/ None); + } + } +} + fn set_panic_hook() { let hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { From 679a944dbcdd5de1e745172268eb9e050d0d6034 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 15:10:13 -0300 Subject: [PATCH 079/141] fix(tui): restore cancelled prompt cursor at end (#26457) ## Why Pressing `Esc` on a turn that produced no visible output restores the submitted prompt so the user can keep editing it. That restore path preserved the prompt content, images, and mention bindings, but left the composer cursor at the start of the restored text. The next edit therefore inserted at the beginning instead of continuing from the end of the prompt. ## What Changed - Move the cursor to the end after `BottomPane::set_composer_text_with_mention_bindings` rehydrates a restored draft. - Add test-only cursor accessors so restore tests can assert the composer state directly. - Extend the queued restore regression to assert the restored composer cursor is positioned at `text.len()`. ## How to Test Manual reviewer flow: 1. Start Codex in the TUI. 2. Submit a prompt that will take long enough to interrupt. 3. Press `Esc` before any visible assistant output appears. 4. Confirm the prompt is restored into the composer and the cursor is at the end, so typing appends to the prompt. 5. Repeat with a prompt that includes an attached image or resolved mention and confirm the restored content remains intact. Targeted tests: - `just test -p codex-tui chatwidget::tests::composer_submission::queued_restore_with_remote_images_keeps_local_placeholder_mapping` Lint note: - `just argument-comment-lint` is blocked locally by the existing Bazel `compiler-rt` empty glob failure before analyzing touched code. The touched Rust diff was manually inspected and adds no new opaque positional literal callsites. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 +++++ codex-rs/tui/src/bottom_pane/mod.rs | 6 ++++++ codex-rs/tui/src/chatwidget/tests/composer_submission.rs | 1 + 3 files changed, 12 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5a39b53aa9..6ebfac4335 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1258,6 +1258,11 @@ impl ChatComposer { self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } + #[cfg(test)] + pub(crate) fn cursor(&self) -> usize { + self.current_cursor() + } + fn history_navigation_cursor(&self) -> usize { if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { 0 diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9bada615f6..cdd4c752d3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -786,6 +786,7 @@ impl BottomPane { local_image_paths, mention_bindings, ); + self.composer.move_cursor_to_end(); self.request_redraw(); } @@ -824,6 +825,11 @@ impl BottomPane { self.composer.current_text() } + #[cfg(test)] + pub(crate) fn composer_cursor(&self) -> usize { + self.composer.cursor() + } + pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { self.composer.draft_snapshot() } diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index f3e32b2eb4..154add1d67 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -730,6 +730,7 @@ async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() { }); assert_eq!(chat.bottom_pane.composer_text(), text); + assert_eq!(chat.bottom_pane.composer_cursor(), text.len()); assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements); assert_eq!(chat.bottom_pane.composer_local_images(), local_images); assert_eq!(chat.remote_image_urls(), remote_image_urls); From 82b15b65e2303255e3e1a740d994c7e00250344a Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 5 Jun 2026 11:20:52 -0700 Subject: [PATCH 080/141] [codex] Respect Windows sandbox backend in exec policy (#26307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Windows managed filesystem permissions can now be backed by a real Windows sandbox. `exec-policy` was still treating the managed read-only policy shape as if there were never a sandbox backend, so benign unmatched commands such as PowerShell directory listings could be rejected with `blocked by policy` even when `windows.sandbox` was enabled. The inverse case still needs to stay conservative: when the Windows sandbox backend is disabled, managed filesystem restrictions are only configuration intent, not an enforced filesystem boundary. That applies to writable-root restricted profiles too, not just read-only profiles. ## What Changed - Thread the effective `WindowsSandboxLevel` into exec-policy approval decisions for shell, unified exec, and intercepted shell exec paths. - Treat managed restricted filesystem profiles as lacking sandbox protection only on Windows when `WindowsSandboxLevel::Disabled`. - Exclude full-disk-write profiles from that no-backend path because they do not rely on filesystem sandbox enforcement. - Remove the cwd-sensitive read-only heuristic and the now-stale cwd plumbing from exec-policy approval contexts. - Add Windows coverage for both enabled-sandbox and disabled-backend behavior, including a writable-root managed profile. ## Validation - Added/updated `exec_policy` coverage for managed filesystem restrictions, full-disk-write exclusion, enabled Windows sandbox behavior, and disabled-backend read-only/writable-root behavior. - `just test -p codex-core exec_policy` — 100 passed, 10 leaky - Empirical local `codex exec` probe with `--sandbox read-only -c 'windows.sandbox="unelevated"'`: PowerShell directory listing completed successfully. - Disabled-backend control with Windows sandbox cleared: the same command was rejected with `blocked by policy`. --- codex-rs/core/src/exec_policy.rs | 34 ++++---- codex-rs/core/src/exec_policy_tests.rs | 64 +++++++++----- .../core/src/exec_policy_windows_tests.rs | 87 ++++++++++++++++++- codex-rs/core/src/session/tests.rs | 3 +- codex-rs/core/src/tools/handlers/shell.rs | 3 +- .../tools/runtimes/shell/unix_escalation.rs | 16 ++-- .../runtimes/shell/unix_escalation_tests.rs | 21 ++--- .../core/src/unified_exec/process_manager.rs | 4 +- codex-rs/core/tests/suite/exec_policy.rs | 71 +++++++++++++++ 9 files changed, 229 insertions(+), 74 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index cc2f18c4fb..7b33a196b0 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -20,6 +20,7 @@ use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::protocol::AskForApproval; @@ -120,7 +121,7 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) sandbox_cwd: &'a Path, + pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, pub(crate) command_origin: ExecPolicyCommandOrigin, @@ -240,7 +241,7 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) sandbox_cwd: &'a Path, + pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, } @@ -274,7 +275,7 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, prefix_rule, } = req; @@ -294,7 +295,7 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin, @@ -631,7 +632,7 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin, @@ -645,15 +646,18 @@ pub(crate) fn render_decision_for_unmatched_command( } }; - // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it - // here. - let environment_lacks_sandbox_protections = - cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd); + // When the Windows sandbox backend is disabled, managed filesystem + // restrictions are only a policy shape; there is no platform sandbox to + // enforce the boundary. Keep that legacy case conservative while still + // relying on the real Windows sandbox when it is enabled. + let windows_managed_fs_restrictions_without_sandbox_backend = cfg!(windows) + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && profile_has_managed_filesystem_restrictions(permission_profile); if is_known_safe && !used_complex_parsing && (approval_policy == AskForApproval::UnlessTrusted - || environment_lacks_sandbox_protections) + || windows_managed_fs_restrictions_without_sandbox_backend) { return Decision::Allow; } @@ -671,7 +675,7 @@ pub(crate) fn render_decision_for_unmatched_command( codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command) } }; - if command_is_dangerous || environment_lacks_sandbox_protections { + if command_is_dangerous || windows_managed_fs_restrictions_without_sandbox_backend { return match approval_policy { AskForApproval::Never => { let sandbox_is_explicitly_disabled = matches!( @@ -740,10 +744,7 @@ pub(crate) fn render_decision_for_unmatched_command( } } -fn profile_is_managed_read_only( - permission_profile: &PermissionProfile, - sandbox_cwd: &Path, -) -> bool { +fn profile_has_managed_filesystem_restrictions(permission_profile: &PermissionProfile) -> bool { let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( @@ -751,9 +752,6 @@ fn profile_is_managed_read_only( FileSystemSandboxKind::Restricted ) && !file_system_sandbox_policy.has_full_disk_write_access() - && file_system_sandbox_policy - .get_writable_roots_with_cwd(sandbox_cwd) - .is_empty() } fn default_policy_path(codex_home: &Path) -> PathBuf { diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 1adf6d471b..a74cd5d2dd 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -15,6 +15,7 @@ use codex_config::Sourced; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -1088,7 +1089,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1108,7 +1109,7 @@ fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalatio UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1128,7 +1129,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1138,7 +1139,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn managed_cwd_write_profile_is_not_read_only() { +fn managed_cwd_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1158,14 +1159,13 @@ fn managed_cwd_write_profile_is_not_read_only() { NetworkSandboxPolicy::Restricted, ); - assert!(!profile_is_managed_read_only( - &permission_profile, - Path::new("/tmp/project") + assert!(profile_has_managed_filesystem_restrictions( + &permission_profile )); } #[test] -fn managed_unresolvable_write_profile_is_still_read_only() { +fn managed_unresolvable_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1188,9 +1188,27 @@ fn managed_unresolvable_write_profile_is_still_read_only() { NetworkSandboxPolicy::Restricted, ); - assert!(profile_is_managed_read_only( - &permission_profile, - Path::new("/tmp/project") + assert!(profile_has_managed_filesystem_restrictions( + &permission_profile + )); +} + +#[test] +fn managed_full_disk_write_profile_has_no_filesystem_restrictions() { + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert!(!profile_has_managed_filesystem_restrictions( + &permission_profile )); } @@ -1317,7 +1335,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1354,7 +1372,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1378,7 +1396,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1403,7 +1421,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1432,7 +1450,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1461,7 +1479,7 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1493,7 +1511,7 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1532,7 +1550,7 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -2013,7 +2031,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2037,7 +2055,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2057,7 +2075,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2152,7 +2170,7 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions, prefix_rule, }) diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 3fba240b41..735cdd4ddc 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -1,6 +1,5 @@ use super::*; use pretty_assertions::assert_eq; -use std::path::Path; #[tokio::test] async fn evaluates_powershell_inner_commands_against_prompt_rules() { @@ -79,7 +78,7 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::PowerShell, @@ -88,6 +87,90 @@ fn unmatched_safe_powershell_words_are_allowed() { ); } +#[test] +fn read_only_windows_sandbox_runs_unmatched_commands_under_sandbox() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + + for windows_sandbox_level in [ + WindowsSandboxLevel::RestrictedToken, + WindowsSandboxLevel::Elevated, + ] { + assert_eq!( + Decision::Allow, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); + } +} + +#[test] +fn read_only_windows_policy_without_sandbox_backend_still_requires_approval() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + + assert_eq!( + Decision::Forbidden, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ), + "command is forbidden because approval policy is never and there is no Windows sandbox to rely on" + ); +} + +#[test] +fn writable_windows_policy_without_sandbox_backend_still_requires_approval() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert_eq!( + Decision::Forbidden, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &permission_profile, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); +} + #[tokio::test] async fn unmatched_dangerous_powershell_inner_commands_require_approval() { let inner_command = vec![ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index abe1d8f07a..cf9c7c48d3 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10881,8 +10881,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - #[allow(deprecated)] - sandbox_cwd: turn_context.cwd.as_path(), + windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b3c955595a..b978a2f577 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -167,8 +167,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result, @@ -622,7 +618,7 @@ impl EscalationPolicy for CoreShellActionProvider { InterceptedExecPolicyContext { approval_policy: self.approval_policy, permission_profile: self.permission_profile.clone(), - sandbox_cwd: self.sandbox_policy_cwd.as_path(), + windows_sandbox_level: self.turn.windows_sandbox_level, sandbox_permissions: self.approval_sandbox_permissions, enable_shell_wrapper_parsing: ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, @@ -671,12 +667,12 @@ fn evaluate_intercepted_exec_policy( policy: &Policy, program: &AbsolutePathBuf, argv: &[String], - context: InterceptedExecPolicyContext<'_>, + context: InterceptedExecPolicyContext, ) -> Evaluation { let InterceptedExecPolicyContext { approval_policy, permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, enable_shell_wrapper_parsing, } = context; @@ -703,7 +699,7 @@ fn evaluate_intercepted_exec_policy( crate::exec_policy::UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin: crate::exec_policy::ExecPolicyCommandOrigin::Generic, @@ -721,10 +717,10 @@ fn evaluate_intercepted_exec_policy( } #[derive(Clone)] -struct InterceptedExecPolicyContext<'a> { +struct InterceptedExecPolicyContext { approval_policy: AskForApproval, permission_profile: PermissionProfile, - sandbox_cwd: &'a Path, + windows_sandbox_level: WindowsSandboxLevel, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index ecdf015fd5..8cfbd0736d 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -16,6 +16,7 @@ use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -456,7 +457,6 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, @@ -508,7 +508,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars parser.parse("test.rules", policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "zsh"])).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let enable_intercepted_exec_policy_shell_wrapper_parsing = false; let evaluation = evaluate_intercepted_exec_policy( @@ -522,7 +521,7 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, }, @@ -560,7 +559,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() parser.parse("test.rules", policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let enable_intercepted_exec_policy_shell_wrapper_parsing = true; let evaluation = evaluate_intercepted_exec_policy( @@ -574,7 +572,7 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, }, @@ -608,7 +606,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) parser.parse("test.rules", &policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(git_path).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let evaluation = evaluate_intercepted_exec_policy( &policy, @@ -617,7 +614,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, }, @@ -670,7 +667,6 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") approval_policy: AskForApproval::OnRequest, permission_profile, file_system_sandbox_policy, - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::UseDefault, approval_sandbox_permissions: SandboxPermissions::UseDefault, prompt_permissions: None, @@ -713,7 +709,6 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow }), permission_profile, file_system_sandbox_policy, - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, @@ -744,7 +739,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( &policy, @@ -753,7 +747,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, /*additional_permissions_preapproved*/ true, @@ -768,7 +762,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, }, @@ -793,7 +787,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) parser.parse("test.rules", &policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let evaluation = evaluate_intercepted_exec_policy( &policy, @@ -802,7 +795,7 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, }, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a4493cf114..cb455b113b 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1019,9 +1019,7 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - // The process cwd may be model-controlled. Policy resolution - // stays anchored to the selected turn environment cwd instead. - sandbox_cwd: request.sandbox_cwd.as_path(), + windows_sandbox_level: context.turn.windows_sandbox_level, sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault } else { diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 6859c6c884..25292149a0 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -87,6 +87,77 @@ fn assert_no_matched_rules_invariant(output_item: &Value) { ); } +#[cfg(windows)] +#[tokio::test] +async fn unified_exec_disabled_windows_sandbox_rejects_managed_read_only_command() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WindowsSandbox) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WindowsSandboxElevated) + .expect("test config should allow feature update"); + config.set_windows_sandbox_enabled(false); + config.set_windows_elevated_sandbox_enabled(false); + }); + let test = builder.build(&server).await?; + let call_id = "unified-exec-disabled-windows-sandbox-read-only"; + let args = json!({ + "cmd": "cmd.exe /c dir", + "yield_time_ms": 1_000, + }); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-disabled-windows-sandbox-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-disabled-windows-sandbox-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-disabled-windows-sandbox-1", "done"), + ev_completed("resp-disabled-windows-sandbox-2"), + ]), + ) + .await; + + submit_user_turn( + &test, + "run unified exec with disabled Windows sandbox", + AskForApproval::Never, + PermissionProfile::read_only(), + None, + ) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_item = results_mock.single_request().function_call_output(call_id); + let Some(output) = output_item.get("output").and_then(Value::as_str) else { + panic!("function_call_output should include string output payload: {output_item:?}"); + }; + assert!( + output.contains("cmd.exe /c dir") && output.contains("rejected: blocked by policy"), + "unexpected output: {output}", + ); + + Ok(()) +} + #[tokio::test] async fn execpolicy_blocks_shell_invocation() -> Result<()> { let mut builder = test_codex().with_config(|config| { From 8d72fb6de92bf332b2ef4a4ccaf992021fbdf4ea Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 5 Jun 2026 11:27:10 -0700 Subject: [PATCH 081/141] [codex] Add turn profiling analytics (#26484) ## Summary Add flat profiling fields to `codex_turn_event` so analytics can explain where turn wall-clock time is spent without changing tool execution behavior. The profile reports: - time before the first sampling request - sampling time across all attempts and follow-ups - overhead between sampling requests - time blocked in the post-sampling tool drain - time after the final sampling request - sampling request and retry counts ## Implementation - Extend the existing turn timing state with constant-memory phase accounting and one RAII phase guard. - Observe sampling and the existing post-sampling drain only at turn orchestration boundaries. - Keep tool runtime, tool futures, response item handling, and turn lifecycle values unchanged. - Add the profiling fields directly to the existing analytics turn event without changing app-server protocol or rollout persistence. - Use the existing turn `status` to distinguish completed, failed, and interrupted profiles. Exact sampling/tool overlap is intentionally omitted because measuring tool completion accurately would require hooks in the tool execution path. ## Validation - Add app-server end-to-end coverage for a single-sampling turn with no blocking tool work. - Add app-server end-to-end coverage for `request_user_input` blocking followed by a second sampling request. - CI is running on the PR; tests were not executed locally per repository guidance. --- .../analytics/src/analytics_client_tests.rs | 40 ++++ codex-rs/analytics/src/client.rs | 7 + codex-rs/analytics/src/events.rs | 7 + codex-rs/analytics/src/facts.rs | 18 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 151 +++++--------- .../app-server/tests/suite/v2/turn_start.rs | 152 +++++++++++++- codex-rs/core/src/session/turn.rs | 9 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/turn_timing.rs | 191 ++++++++++++++++++ codex-rs/core/src/turn_timing_tests.rs | 41 ++++ 11 files changed, 530 insertions(+), 101 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7177952c37..f4cd18da1e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -63,6 +63,8 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfile; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -396,6 +398,18 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo } } +fn sample_turn_profile() -> TurnProfile { + TurnProfile { + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 50, + tool_blocking_ms: 250, + after_last_sampling_ms: 134, + sampling_request_count: 2, + sampling_retry_count: 1, + } +} + fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -649,6 +663,18 @@ async fn ingest_turn_prerequisites( ) .await; } + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new( + TurnProfileFact { + turn_id: "turn-2".to_string(), + profile: sample_turn_profile(), + }, + ))), + out, + ) + .await; } async fn ingest_review_prerequisites( @@ -3300,6 +3326,13 @@ fn turn_event_serializes_expected_shape() { output_tokens: None, reasoning_output_tokens: None, total_tokens: None, + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 50, + tool_blocking_ms: 250, + after_last_sampling_ms: 134, + sampling_request_count: 2, + sampling_retry_count: 1, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -3366,6 +3399,13 @@ fn turn_event_serializes_expected_shape() { "output_tokens": null, "reasoning_output_tokens": null, "total_tokens": null, + "before_first_sampling_ms": 100, + "sampling_ms": 700, + "between_sampling_overhead_ms": 50, + "tool_blocking_ms": 250, + "after_last_sampling_ms": 134, + "sampling_request_count": 2, + "sampling_retry_count": 1, "duration_ms": 1234, "started_at": 455, "completed_at": 456 diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bd0726b28e..b99a2ec86f 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -19,6 +19,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -257,6 +258,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_profile(&self, fact: TurnProfileFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile( + Box::new(fact), + ))); + } + pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError( Box::new(fact), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index bdc2c996dc..f0fcddd27f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -817,6 +817,13 @@ pub(crate) struct CodexTurnEventParams { pub(crate) output_tokens: Option, pub(crate) reasoning_output_tokens: Option, pub(crate) total_tokens: Option, + pub(crate) before_first_sampling_ms: u64, + pub(crate) sampling_ms: u64, + pub(crate) between_sampling_overhead_ms: u64, + pub(crate) tool_blocking_ms: u64, + pub(crate) after_last_sampling_ms: u64, + pub(crate) sampling_request_count: u32, + pub(crate) sampling_retry_count: u32, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1dff5de89d..38af5ed8b8 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -104,6 +104,23 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TurnProfile { + pub before_first_sampling_ms: u64, + pub sampling_ms: u64, + pub between_sampling_overhead_ms: u64, + pub tool_blocking_ms: u64, + pub after_last_sampling_ms: u64, + pub sampling_request_count: u32, + pub sampling_retry_count: u32, +} + +#[derive(Clone)] +pub struct TurnProfileFact { + pub turn_id: String, + pub profile: TurnProfile, +} + #[derive(Clone)] pub struct TurnCodexErrorFact { pub(crate) turn_id: String, @@ -476,6 +493,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnProfile(Box), TurnCodexError(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index c227f4daf1..e2e16dfacd 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -39,6 +39,8 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnCodexErrorFact; +pub use facts::TurnProfile; +pub use facts::TurnProfileFact; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 877000928f..66caaafe01 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -72,6 +72,8 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnCodexError; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfile; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -316,6 +318,7 @@ struct CompletedTurnState { duration_ms: Option, } +#[derive(Default)] struct TurnState { connection_id: Option, thread_id: Option, @@ -323,6 +326,7 @@ struct TurnState { resolved_config: Option, started_at: Option, token_usage: Option, + profile: Option, completed: Option, codex_error: Option, latest_diff: Option, @@ -464,6 +468,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out).await; } + CustomAnalyticsFact::TurnProfile(input) => { + self.ingest_turn_profile(*input, out).await; + } CustomAnalyticsFact::TurnCodexError(input) => { self.ingest_turn_codex_error(*input); } @@ -604,19 +611,7 @@ impl AnalyticsReducer { let turn_id = input.turn_id.clone(); let thread_id = input.thread_id.clone(); let num_input_images = input.num_input_images; - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); turn_state.resolved_config = Some(input); @@ -629,43 +624,30 @@ impl AnalyticsReducer { out: &mut Vec, ) { let turn_id = input.turn_id.clone(); - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); self.maybe_emit_turn_event(&turn_id, out).await; } + async fn ingest_turn_profile( + &mut self, + input: TurnProfileFact, + out: &mut Vec, + ) { + let TurnProfileFact { turn_id, profile } = input; + let turn_state = self.turns.entry(turn_id.clone()).or_default(); + turn_state.profile = Some(profile); + self.maybe_emit_turn_event(&turn_id, out).await; + } + fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) { let TurnCodexErrorFact { turn_id, thread_id, error, } = input; - let turn_state = self.turns.entry(turn_id).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id).or_default(); turn_state.thread_id.get_or_insert(thread_id); turn_state.codex_error = Some(error); } @@ -818,19 +800,7 @@ impl AnalyticsReducer { else { return; }; - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); turn_state.num_input_images = Some(pending_request.num_input_images); @@ -1178,61 +1148,19 @@ impl AnalyticsReducer { self.ingest_guardian_review_completed(notification, out); } ServerNotification::TurnStarted(notification) => { - let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn.id).or_default(); turn_state.started_at = notification .turn .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } ServerNotification::TurnDiffUpdated(notification) => { - let turn_state = - self.turns - .entry(notification.turn_id.clone()) - .or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn_id.clone()).or_default(); turn_state.thread_id = Some(notification.thread_id); turn_state.latest_diff = Some(notification.diff); } ServerNotification::TurnCompleted(notification) => { - let turn_state = - self.turns - .entry(notification.turn.id.clone()) - .or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn.id.clone()).or_default(); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), turn_error: notification @@ -1511,6 +1439,7 @@ impl AnalyticsReducer { if turn_state.thread_id.is_none() || turn_state.num_input_images.is_none() || turn_state.resolved_config.is_none() + || turn_state.profile.is_none() || turn_state.completed.is_none() { return; @@ -2457,12 +2386,20 @@ fn codex_turn_event_params( turn_state: &TurnState, thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { - let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + let ( + Some(thread_id), + Some(num_input_images), + Some(resolved_config), + Some(profile), + Some(completed), + ) = ( turn_state.thread_id.clone(), turn_state.num_input_images, turn_state.resolved_config.clone(), + turn_state.profile.clone(), turn_state.completed.clone(), - ) else { + ) + else { unreachable!("turn event params require a fully populated turn state"); }; let started_at = turn_state.started_at; @@ -2488,6 +2425,15 @@ fn codex_turn_event_params( workspace_kind, is_first_turn, } = resolved_config; + let TurnProfile { + before_first_sampling_ms, + sampling_ms, + between_sampling_overhead_ms, + tool_blocking_ms, + after_last_sampling_ms, + sampling_request_count, + sampling_retry_count, + } = profile; let token_usage = turn_state.token_usage.clone(); let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { @@ -2550,6 +2496,13 @@ fn codex_turn_event_params( total_tokens: token_usage .as_ref() .map(|token_usage| token_usage.total_tokens), + before_first_sampling_ms, + sampling_ms, + between_sampling_overhead_ms, + tool_blocking_ms, + after_last_sampling_ms, + sampling_request_count, + sampling_retry_count, duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 9f6a027a75..b4da5cddea 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -9,6 +9,7 @@ use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_request_user_input_sse_response; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; @@ -78,6 +79,7 @@ use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::ResponseTemplate; use super::analytics::mount_analytics_capture; use super::analytics::wait_for_analytics_event; @@ -819,8 +821,20 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> #[tokio::test] async fn turn_start_tracks_turn_event_analytics() -> Result<()> { - let responses = vec![create_final_assistant_message_sse_response("Done")?]; - let server = create_mock_responses_server_sequence_unchecked(responses).await; + let server = responses::start_mock_server().await; + let response_mock = responses::mount_response_sequence( + &server, + vec![ + ResponseTemplate::new(500).set_body_json(json!({ + "error": { + "type": "server_error", + "message": "synthetic retryable error" + } + })), + responses::sse_response(create_final_assistant_message_sse_response("Done")?), + ], + ) + .await; let codex_home = TempDir::new()?; write_mock_responses_config_toml_with_chatgpt_base_url( @@ -828,6 +842,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { &server.uri(), &server.uri(), )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)? + .replace("stream_max_retries = 0", "stream_max_retries = 1"); + std::fs::write(config_path, config)?; mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; @@ -908,6 +926,136 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["output_tokens"], 0); assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); assert_eq!(event["event_params"]["total_tokens"], 0); + let params = &event["event_params"]; + let timings_are_numbers = [ + "before_first_sampling_ms", + "sampling_ms", + "between_sampling_overhead_ms", + "tool_blocking_ms", + "after_last_sampling_ms", + ] + .into_iter() + .all(|field| params[field].as_u64().is_some()); + assert_eq!( + json!({ + "timingsAreNumbers": timings_are_numbers, + "toolBlockingMs": params["tool_blocking_ms"], + "samplingRequestCount": params["sampling_request_count"], + "samplingRetryCount": params["sampling_retry_count"], + "responseRequestCount": response_mock.requests().len(), + }), + json!({ + "timingsAreNumbers": true, + "toolBlockingMs": 0, + "samplingRequestCount": 2, + "samplingRetryCount": 1, + "responseRequestCount": 2, + }) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn turn_profile_tracks_blocking_tool_and_follow_up_sampling() -> Result<()> { + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, .. } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + mcp.send_response( + request_id, + json!({ + "answers": { + "confirm_path": { "answers": ["yes"] } + } + }), + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + let params = &event["event_params"]; + assert_eq!( + json!({ + "toolBlockingIsPositive": params["tool_blocking_ms"] + .as_u64() + .is_some_and(|duration| duration > 0), + "samplingRequestCount": params["sampling_request_count"], + "samplingRetryCount": params["sampling_retry_count"], + "status": params["status"], + }), + json!({ + "toolBlockingIsPositive": true, + "samplingRequestCount": 2, + "samplingRetryCount": 0, + "status": "completed", + }) + ); Ok(()) } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 5853b78c7e..76303345a7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1088,6 +1088,7 @@ async fn run_sampling_request( ResponsesStreamRequest::Sampling, ) .await?; + turn_context.turn_timing_state.record_sampling_retry(); } } @@ -1800,6 +1801,7 @@ async fn try_run_sampling_request( turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); + let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); let mut stream = client_session .stream( prompt, @@ -2213,6 +2215,7 @@ async fn try_run_sampling_request( } } }; + drop(sampling_timing_guard); flush_assistant_text_segments_all( &sess, @@ -2222,7 +2225,13 @@ async fn try_run_sampling_request( ) .await; + let tool_blocking_timing_guard = if in_flight.is_empty() { + None + } else { + Some(turn_context.turn_timing_state.begin_tool_blocking()) + }; drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + drop(tool_blocking_timing_guard); if should_emit_token_count { // A tool call such as request_user_input can intentionally pause the turn. Emit token diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ef2ba0f16d..70466c9c2f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -33,6 +33,7 @@ use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnProfileFact; use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; @@ -751,6 +752,12 @@ impl Session { .turn_timing_state .time_to_first_token_ms() .await; + self.services + .analytics_events_client + .track_turn_profile(TurnProfileFact { + turn_id: turn_context.sub_id.clone(), + profile: turn_context.turn_timing_state.complete_profile(), + }); self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) .await; if let Err(err) = self @@ -868,6 +875,12 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + self.services + .analytics_events_client + .track_turn_profile(TurnProfileFact { + turn_id: task.turn_context.sub_id.clone(), + profile: task.turn_context.turn_timing_state.complete_profile(), + }); let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 445fe16214..380e246fae 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use codex_analytics::TurnProfile; use codex_otel::TURN_TTFM_DURATION_METRIC; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; @@ -39,6 +42,7 @@ pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &T #[derive(Debug, Default)] pub(crate) struct TurnTimingState { state: Mutex, + profile: StdMutex, } #[derive(Debug, Default)] @@ -49,6 +53,35 @@ struct TurnTimingStateInner { first_message_at: Option, } +#[derive(Debug, Default)] +struct TurnProfileState { + started_at: Option, + last_transition_at: Option, + active_phase: Option, + seen_sampling: bool, + before_first_sampling: Duration, + sampling: Duration, + between_sampling_overhead: Duration, + tool_blocking: Duration, + pending_idle_after_sampling: Duration, + sampling_request_count: u32, + sampling_retry_count: u32, + completed_profile: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TurnProfilePhase { + Sampling, + ToolBlocking, +} + +#[must_use] +pub(crate) struct TurnProfileTimingGuard { + timing: Arc, + phase: TurnProfilePhase, + active: bool, +} + impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) -> i64 { let started_at_unix_ms = now_unix_timestamp_ms(); @@ -57,6 +90,7 @@ impl TurnTimingState { state.started_at_unix_secs = Some(started_at_unix_ms / 1000); state.first_token_at = None; state.first_message_at = None; + self.profile_state().start(started_at); started_at_unix_ms } @@ -80,6 +114,32 @@ impl TurnTimingState { .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) } + pub(crate) fn complete_profile(&self) -> TurnProfile { + self.profile_state().complete(Instant::now()) + } + + pub(crate) fn begin_sampling(self: &Arc) -> TurnProfileTimingGuard { + let active = self.profile_state().begin_sampling(Instant::now()); + TurnProfileTimingGuard { + timing: Arc::clone(self), + phase: TurnProfilePhase::Sampling, + active, + } + } + + pub(crate) fn record_sampling_retry(&self) { + self.profile_state().record_sampling_retry(); + } + + pub(crate) fn begin_tool_blocking(self: &Arc) -> TurnProfileTimingGuard { + let active = self.profile_state().begin_tool_blocking(Instant::now()); + TurnProfileTimingGuard { + timing: Arc::clone(self), + phase: TurnProfilePhase::ToolBlocking, + active, + } + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -98,6 +158,22 @@ impl TurnTimingState { let mut state = self.state.lock().await; state.record_turn_ttfm() } + + fn profile_state(&self) -> std::sync::MutexGuard<'_, TurnProfileState> { + self.profile + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl Drop for TurnProfileTimingGuard { + fn drop(&mut self) { + if self.active { + self.timing + .profile_state() + .end_phase(Instant::now(), self.phase); + } + } } fn now_unix_timestamp_secs() -> i64 { @@ -111,6 +187,121 @@ pub(crate) fn now_unix_timestamp_ms() -> i64 { i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) } +fn duration_to_u64_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +impl TurnProfileState { + fn start(&mut self, started_at: Instant) { + *self = Self { + started_at: Some(started_at), + last_transition_at: Some(started_at), + ..Self::default() + }; + } + + fn begin_sampling(&mut self, now: Instant) -> bool { + if self.completed_profile.is_some() + || self.started_at.is_none() + || self.active_phase.is_some() + { + return false; + } + self.advance(now); + if self.seen_sampling { + self.between_sampling_overhead += std::mem::take(&mut self.pending_idle_after_sampling); + } + self.seen_sampling = true; + self.active_phase = Some(TurnProfilePhase::Sampling); + self.sampling_request_count = self.sampling_request_count.saturating_add(1); + true + } + + fn record_sampling_retry(&mut self) { + if self.completed_profile.is_none() && self.started_at.is_some() { + self.sampling_retry_count = self.sampling_retry_count.saturating_add(1); + } + } + + fn begin_tool_blocking(&mut self, now: Instant) -> bool { + if self.completed_profile.is_some() + || self.started_at.is_none() + || self.active_phase.is_some() + { + return false; + } + self.advance(now); + self.active_phase = Some(TurnProfilePhase::ToolBlocking); + true + } + + fn end_phase(&mut self, now: Instant, phase: TurnProfilePhase) { + if self.completed_profile.is_some() || self.active_phase != Some(phase) { + return; + } + self.advance(now); + self.active_phase = None; + } + + fn advance(&mut self, now: Instant) { + let Some(previous) = self.last_transition_at.replace(now) else { + return; + }; + let elapsed = now.saturating_duration_since(previous); + match self.active_phase { + Some(TurnProfilePhase::Sampling) => self.sampling += elapsed, + Some(TurnProfilePhase::ToolBlocking) => self.tool_blocking += elapsed, + None if self.seen_sampling => self.pending_idle_after_sampling += elapsed, + None => self.before_first_sampling += elapsed, + } + } + + fn complete(&mut self, now: Instant) -> TurnProfile { + if let Some(profile) = self.completed_profile.as_ref() { + return profile.clone(); + } + + let final_phase = self.active_phase; + self.advance(now); + let after_last_sampling = if self.seen_sampling { + std::mem::take(&mut self.pending_idle_after_sampling) + } else { + Duration::ZERO + }; + + let mut profile = TurnProfile { + before_first_sampling_ms: duration_to_u64_ms(self.before_first_sampling), + sampling_ms: duration_to_u64_ms(self.sampling), + between_sampling_overhead_ms: duration_to_u64_ms(self.between_sampling_overhead), + tool_blocking_ms: duration_to_u64_ms(self.tool_blocking), + after_last_sampling_ms: duration_to_u64_ms(after_last_sampling), + sampling_request_count: self.sampling_request_count, + sampling_retry_count: self.sampling_retry_count, + }; + let total_ms = self + .started_at + .map(|started_at| duration_to_u64_ms(now.saturating_duration_since(started_at))) + .unwrap_or_default(); + let classified_ms = profile + .before_first_sampling_ms + .saturating_add(profile.sampling_ms) + .saturating_add(profile.between_sampling_overhead_ms) + .saturating_add(profile.tool_blocking_ms) + .saturating_add(profile.after_last_sampling_ms); + let rounding_ms = total_ms.saturating_sub(classified_ms); + match final_phase { + Some(TurnProfilePhase::Sampling) => profile.sampling_ms += rounding_ms, + Some(TurnProfilePhase::ToolBlocking) => profile.tool_blocking_ms += rounding_ms, + None if self.seen_sampling => profile.after_last_sampling_ms += rounding_ms, + None => profile.before_first_sampling_ms += rounding_ms, + } + + self.active_phase = None; + self.completed_profile = Some(profile.clone()); + profile + } +} + impl TurnTimingStateInner { fn time_to_first_token(&self) -> Option { Some(self.first_token_at?.duration_since(self.started_at?)) diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index a6675aea8e..7146f537be 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -1,13 +1,17 @@ +use codex_analytics::TurnProfile; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use std::time::Duration; use std::time::Instant; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use super::TurnProfilePhase; +use super::TurnProfileState; use super::TurnTimingState; use super::response_item_records_turn_ttft; use crate::ResponseEvent; @@ -146,3 +150,40 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { } )); } + +#[test] +fn turn_profile_breaks_down_sampling_blocking_and_retry_overhead() { + let started_at = Instant::now(); + let mut state = TurnProfileState::default(); + state.start(started_at); + + let _ = state.begin_sampling(started_at + Duration::from_millis(100)); + state.end_phase( + started_at + Duration::from_millis(600), + TurnProfilePhase::Sampling, + ); + let _ = state.begin_tool_blocking(started_at + Duration::from_millis(600)); + state.end_phase( + started_at + Duration::from_millis(900), + TurnProfilePhase::ToolBlocking, + ); + state.record_sampling_retry(); + let _ = state.begin_sampling(started_at + Duration::from_millis(1_000)); + state.end_phase( + started_at + Duration::from_millis(1_200), + TurnProfilePhase::Sampling, + ); + + assert_eq!( + state.complete(started_at + Duration::from_millis(1_300)), + TurnProfile { + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 100, + tool_blocking_ms: 300, + after_last_sampling_ms: 100, + sampling_request_count: 2, + sampling_retry_count: 1, + } + ); +} From 76c0a5379c79cc6eafc3a041791201e849ad4c7f Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 11:36:53 -0700 Subject: [PATCH 082/141] Make runtime workspace roots absolute in app-server API (#26552) Stacked on #26532. ## Why #26532 moves cwd normalization to the app-server/core boundary. `runtimeWorkspaceRoots` still accepted raw paths in v2 requests and in `ConfigOverrides`, which left core responsible for interpreting those roots later. This makes runtime workspace roots follow the same absolute-path boundary as cwd. ## What - Change v2 `runtimeWorkspaceRoots` request fields for `thread/start`, `thread/resume`, `thread/fork`, and `turn/start` to `AbsolutePathBuf`. - Deduplicate already-absolute runtime roots in app-server handlers and pass them through `ConfigOverrides.workspace_roots` as `AbsolutePathBuf`. - Update TUI and exec client request builders to pass absolute runtime roots directly. - Update app-server docs, schema fixtures, and focused tests for absolute runtime roots. ## Testing - `just test -p codex-app-server-protocol` - `just test -p codex-app-server runtime_workspace_roots` - `just test -p codex-core session_permission_profile_rebinds_runtime_workspace_roots` - `just test -p codex-tui app_server_session` - `just test -p codex-exec` --- .../schema/json/v2/ThreadForkParams.json | 4 ++ .../schema/json/v2/ThreadResumeParams.json | 4 ++ .../src/protocol/v2/thread.rs | 15 ++--- .../src/protocol/v2/turn.rs | 5 +- codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/request_processors.rs | 18 ++++++ .../request_processors/thread_processor.rs | 17 ++---- .../src/request_processors/turn_processor.rs | 58 +++---------------- .../tests/suite/v2/thread_resume.rs | 5 +- .../app-server/tests/suite/v2/thread_start.rs | 13 ++--- .../app-server/tests/suite/v2/turn_start.rs | 2 + codex-rs/core/src/config/mod.rs | 13 ++--- codex-rs/exec/src/lib.rs | 16 +---- codex-rs/tui/src/app_server_session.rs | 47 ++------------- 14 files changed, 73 insertions(+), 148 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 9d2f834dd8..d9a543e939 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index a6abcc5665..dc5ef5cdaa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AgentMessageInputContent": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 9dab60a046..486e121cf7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -107,11 +107,10 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/start.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -358,11 +357,10 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/resume.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -509,11 +507,10 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/fork.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index f9bfe21479..3bac7bf7ec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -88,11 +88,10 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub cwd: Option, /// Replace the thread's runtime workspace roots for this turn and - /// subsequent turns. Relative paths are resolved against the effective - /// cwd for the turn. + /// subsequent turns. Paths must be absolute. #[experimental("turn/start.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e06dc16e3..a12144238a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,7 +130,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. @@ -158,7 +158,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 00468b0a99..bfcf328c43 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -513,6 +513,24 @@ use crate::thread_state::ThreadStateManager; use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; use token_usage_replay::send_thread_token_usage_update_to_connection; +fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { + cwd.map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) + }) + .transpose() +} + +fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { + let mut resolved_roots = Vec::new(); + for root in workspace_roots { + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + mod config_errors; mod request_errors; mod thread_goal_processor; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index efff8e7cd8..eab606bf62 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -56,17 +56,7 @@ fn collect_resume_override_mismatches( } } if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { - let base_cwd = request - .cwd - .as_deref() - .map(|cwd| { - AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) - }) - .unwrap_or_else(|| config_snapshot.cwd.clone()); - let requested_runtime_workspace_roots = requested_runtime_workspace_roots - .iter() - .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) - .collect::>(); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots.to_vec(); if requested_runtime_workspace_roots != config_snapshot.workspace_roots { mismatch_details.push(format!( "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", @@ -846,6 +836,7 @@ impl ThreadRequestProcessor { )); } let environment_selections = self.parse_environment_selections(environments)?; + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -1215,7 +1206,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, - runtime_workspace_roots: Option>, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -2483,6 +2474,7 @@ impl ThreadRequestProcessor { }; let history_cwd = thread_history.session_cwd(); + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -3201,6 +3193,7 @@ impl ThreadRequestProcessor { } else { Some(cli_overrides) }; + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 85342235c9..ce98a2e8e0 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -18,28 +18,6 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } -fn resolve_runtime_workspace_roots( - workspace_roots: Vec, - base_cwd: &AbsolutePathBuf, -) -> Vec { - let mut resolved_roots = Vec::new(); - for path in workspace_roots { - let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); - if !resolved_roots.iter().any(|existing| existing == &root) { - resolved_roots.push(root); - } - } - resolved_roots -} - -fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { - cwd.map(|cwd| { - AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) - .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) - }) - .transpose() -} - fn map_additional_context( additional_context: Option>, ) -> BTreeMap { @@ -66,7 +44,7 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, cwd: Option, - runtime_workspace_roots: Option>, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -533,7 +511,7 @@ impl TurnRequestProcessor { // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for // `thread/settings/updated` or combine the fields in one request. - let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() { + let snapshot = if permissions.is_some() { Some(thread.config_snapshot().await) } else { None @@ -552,22 +530,8 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = if let Some(workspace_roots) = - runtime_workspace_roots_request.clone() - { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error(format!( - "{method} runtime workspace roots missing thread snapshot" - ))); - }; - let base_cwd = cwd - .as_ref() - .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) - .unwrap_or_else(|| snapshot.cwd.clone()); - Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) - } else { - None - }; + let runtime_workspace_roots = + runtime_workspace_roots_request.map(resolve_runtime_workspace_roots); let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -582,15 +546,11 @@ impl TurnRequestProcessor { }; let overrides = ConfigOverrides { cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), - workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( - || { - snapshot - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - }, - )), + workspace_roots: Some( + runtime_workspace_roots + .clone() + .unwrap_or_else(|| snapshot.workspace_roots.clone()), + ), default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4ae68b0e71..582177ee7d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -366,7 +366,10 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul text: "Hello".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + runtime_workspace_roots: Some(vec![ + AbsolutePathBuf::from_absolute_path(&extra_root)?, + AbsolutePathBuf::from_absolute_path(extra_root.join("."))?, + ]), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index a2da95545a..5954342b9c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -197,15 +197,15 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { } #[tokio::test] -async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { +async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let cwd_tmp = TempDir::new()?; let cwd = cwd_tmp.path().to_path_buf(); - let relative_root = PathBuf::from("extra-root"); - std::fs::create_dir_all(cwd.join(&relative_root))?; + let extra_root = cwd.join("extra-root"); + std::fs::create_dir_all(&extra_root)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -213,7 +213,7 @@ async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<( let req_id = mcp .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some(vec![relative_root.clone()]), + runtime_workspace_roots: Some(vec![extra_root.abs()]), ..Default::default() }) .await?; @@ -230,10 +230,7 @@ async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<( } = to_response::(resp)?; assert_eq!(response_cwd, cwd.abs()); - assert_eq!( - runtime_workspace_roots, - vec![cwd_tmp.path().join(relative_root).abs()] - ); + assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b4da5cddea..aed3c58697 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2449,6 +2449,8 @@ async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_t std::fs::create_dir(&new_root)?; let old_root_text = old_root.to_string_lossy().into_owned(); let new_root_text = new_root.to_string_lossy().into_owned(); + let old_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(old_root)?; + let new_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(new_root)?; let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b74f2feefe..e319f3b396 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2245,9 +2245,9 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, - /// Explicit runtime workspace roots for this session. When set, this is - /// the full runtime root list rather than an additive override. - pub workspace_roots: Option>, + /// Explicit absolute runtime workspace roots for this session. When set, + /// this is the full runtime root list rather than an additive override. + pub workspace_roots: Option>, } fn dedupe_absolute_paths(paths: &mut Vec) { @@ -2821,12 +2821,7 @@ impl Config { || !requested_additional_writable_roots.is_empty() || legacy_workspace_roots_explicit; let mut workspace_roots = match workspace_roots_override { - Some(workspace_roots) => workspace_roots - .into_iter() - .map(|path| { - AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) - }) - .collect(), + Some(workspace_roots) => workspace_roots, None => { let mut workspace_roots = vec![resolved_cwd.clone()]; workspace_roots.extend(requested_additional_writable_roots.clone()); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 797e168dc5..b8b87a85ec 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1044,13 +1044,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -1075,13 +1069,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ccd6304d76..438ee21ca5 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -708,12 +708,7 @@ impl AppServerSession { additional_context: None, environments: None, cwd: Some(cwd), - runtime_workspace_roots: Some( - workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(workspace_roots.to_vec()), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, @@ -1403,13 +1398,7 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1444,13 +1433,7 @@ fn thread_resume_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1482,13 +1465,7 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1885,13 +1862,7 @@ mod tests { assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); assert_eq!( params.runtime_workspace_roots, - Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - ) + Some(config.workspace_roots.clone()) ); assert_eq!(params.sandbox, None); assert_eq!( @@ -2009,13 +1980,7 @@ mod tests { &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); - let expected_runtime_workspace_roots = Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>(), - ); + let expected_runtime_workspace_roots = Some(config.workspace_roots.clone()); let start = thread_start_params_from_config( &config, From 345cf6e8d06b0d6890e68dc5fd80227f2fbb4e71 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 5 Jun 2026 14:58:09 -0400 Subject: [PATCH 083/141] Use state DB first for `resume --last` (#26462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `codex resume --last` currently lists sessions by updated time using scan-and-repair. Updated-time filesystem listing must stat every rollout before applying the cwd, provider, and source filters, so startup scales with the entire local session history... This change queries the state DB first for the latest matching session. For local workspaces, we only accept the indexed result when its rollout path still exists; otherwise we retry with scan-and-repair. The same lookup path is shared by `fork --last`. I benchmarked the same `thread/list` request used by `resume --last` in my local `ruff` checkout against a Codex home with 2,599 active rollouts totaling 3.7 GiB, including 90 Ruff threads. Across five fresh release app-server processes with warm filesystem caches, the state-DB-only lookup had median latency of 0.37-0.44 ms, while scan-and-repair had median latency of 139-162 ms. First-request latency was 0.7-1.7 ms versus 142-185 ms. So this **removes roughly 140-160 ms from the `resume --last` lookup** on this machine, and makes that lookup over 300x faster. The tradeoff is that this does leave two correctness gaps: - If a newer matching rollout is missing from SQLite but an older matching row exists, the fast path resumes the older thread and never falls back to the filesystem scan. - If an existing row has stale filter or ordering metadata, the fast path can select a different thread from scan-and-repair. The rollout tests already demonstrate this for stale cwd metadata: state-DB-only returns the stale match, while scan-and-repair removes and repairs it. So you could end up seeing the "wrong" result in cases like... 1. A crash or SQLite error occurs between Codex writing the conversation file and updating SQLite, leaving the newer file unindexed. 2. An older Codex version, restore, or manual copy adds a conversation file after SQLite’s one-time backfill completed. These seem pretty rare though (and sessions can always be recovered via other mechanisms -- `--last` is just a convenience feature), and I think the tradeoffs are good here? --- codex-rs/tui/src/lib.rs | 280 ++++++++++++++++++++++++++-------------- 1 file changed, 185 insertions(+), 95 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b53ec0f83d..ebaccf08fc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -744,18 +744,37 @@ async fn lookup_latest_session_target_with_app_server( cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> color_eyre::Result> { - let response = app_server - .thread_list(latest_session_lookup_params( - app_server.uses_remote_workspace(), - config, - cwd_filter, - include_non_interactive, - )) - .await?; - Ok(response - .data - .into_iter() - .find_map(session_target_from_app_server_thread)) + let uses_remote_workspace = app_server.uses_remote_workspace(); + for lookup_mode in [ + LatestSessionLookupMode::StateDbOnly, + LatestSessionLookupMode::ScanAndRepair, + ] { + let response = app_server + .thread_list(latest_session_lookup_params( + uses_remote_workspace, + config, + cwd_filter, + include_non_interactive, + lookup_mode, + )) + .await?; + let target = response + .data + .into_iter() + .find_map(session_target_from_app_server_thread); + if target.as_ref().is_some_and(|target| { + uses_remote_workspace || target.path.as_deref().is_some_and(std::path::Path::exists) + }) { + return Ok(target); + } + } + Ok(None) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LatestSessionLookupMode { + StateDbOnly, + ScanAndRepair, } fn latest_session_lookup_params( @@ -763,6 +782,7 @@ fn latest_session_lookup_params( config: &Config, cwd_filter: Option<&Path>, include_non_interactive: bool, + lookup_mode: LatestSessionLookupMode, ) -> ThreadListParams { ThreadListParams { cursor: None, @@ -777,7 +797,10 @@ fn latest_session_lookup_params( source_kinds: Some(resume_source_kinds(include_non_interactive)), archived: Some(false), cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())), - use_state_db_only: false, + use_state_db_only: match lookup_mode { + LatestSessionLookupMode::StateDbOnly => true, + LatestSessionLookupMode::ScanAndRepair => false, + }, search_term: None, } } @@ -2040,6 +2063,85 @@ mod tests { .await } + fn write_session_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: &str, + cwd: &Path, + ) -> color_eyre::Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let thread_id = ThreadId::from_string(&uuid_str)?; + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let rollout_path = codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); + let parent = rollout_path + .parent() + .ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?; + std::fs::create_dir_all(parent)?; + + let session_meta = codex_protocol::protocol::SessionMeta { + id: thread_id, + timestamp: meta_rfc3339.to_string(), + cwd: cwd.to_path_buf(), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::Cli, + model_provider: Some(model_provider.to_string()), + ..Default::default() + }; + let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { + meta: session_meta, + git: None, + })?; + let lines = [ + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": session_meta, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": preview}], + }, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": preview, + "kind": "plain", + }, + }) + .to_string(), + ]; + std::fs::write(&rollout_path, lines.join("\n") + "\n")?; + let updated_at = + chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = std::fs::FileTimes::new().set_modified(updated_at.into()); + std::fs::OpenOptions::new() + .append(true) + .open(rollout_path)? + .set_times(times)?; + + Ok(thread_id) + } + #[test] fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -2328,13 +2430,27 @@ mod tests { &config, Some(cwd.as_path()), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); - assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); + assert_eq!( + params.model_providers, + Some(vec![config.model_provider_id.clone()]) + ); assert_eq!( params.cwd, Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) ); + assert!(params.use_state_db_only); + + let scan_params = latest_session_lookup_params( + /*uses_remote_workspace*/ false, + &config, + Some(cwd.as_path()), + /*include_non_interactive*/ false, + LatestSessionLookupMode::ScanAndRepair, + ); + assert!(!scan_params.use_state_db_only); Ok(()) } @@ -2355,6 +2471,7 @@ mod tests { &config, Some(cwd.as_path()), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); @@ -2372,8 +2489,11 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, + &config, + /*cwd_filter*/ None, /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, None); @@ -2388,8 +2508,11 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, + &config, + /*cwd_filter*/ None, /*include_non_interactive*/ true, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!( @@ -2416,6 +2539,7 @@ mod tests { &config, Some(cwd), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, None); @@ -2455,85 +2579,6 @@ mod tests { #[tokio::test] async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { - fn write_session_rollout( - codex_home: &Path, - filename_ts: &str, - meta_rfc3339: &str, - preview: &str, - model_provider: &str, - cwd: &Path, - ) -> color_eyre::Result { - let uuid = Uuid::new_v4(); - let uuid_str = uuid.to_string(); - let thread_id = ThreadId::from_string(&uuid_str)?; - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let rollout_path = codex_home - .join("sessions") - .join(year) - .join(month) - .join(day) - .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); - let parent = rollout_path.parent().ok_or_else(|| { - color_eyre::eyre::eyre!("rollout path is missing a parent directory") - })?; - std::fs::create_dir_all(parent)?; - - let session_meta = codex_protocol::protocol::SessionMeta { - id: thread_id, - timestamp: meta_rfc3339.to_string(), - cwd: cwd.to_path_buf(), - originator: "codex".to_string(), - cli_version: "0.0.0".to_string(), - source: codex_protocol::protocol::SessionSource::Cli, - model_provider: Some(model_provider.to_string()), - ..Default::default() - }; - let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { - meta: session_meta, - git: None, - })?; - let lines = [ - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": session_meta, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "response_item", - "payload": { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": preview}], - }, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "event_msg", - "payload": { - "type": "user_message", - "message": preview, - "kind": "plain", - }, - }) - .to_string(), - ]; - std::fs::write(&rollout_path, lines.join("\n") + "\n")?; - let updated_at = - chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); - let times = std::fs::FileTimes::new().set_modified(updated_at.into()); - std::fs::OpenOptions::new() - .append(true) - .open(rollout_path)? - .set_times(times)?; - - Ok(thread_id) - } - let temp_dir = TempDir::new()?; let project_cwd = temp_dir.path().join("project"); let other_cwd = temp_dir.path().join("other-project"); @@ -2603,6 +2648,51 @@ mod tests { Ok(()) } + #[tokio::test] + async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db() + -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let project_cwd = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(project_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let mut app_server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config.clone()).await?, + ), + ThreadParamsMode::Embedded, + ); + + // Simulate a legacy writer creating a rollout after the state DB backfill completed. + let thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "legacy writer session", + config.model_provider_id.as_str(), + &project_cwd, + )?; + + let target = lookup_latest_session_target_with_app_server( + &mut app_server, + &config, + Some(project_cwd.as_path()), + /*include_non_interactive*/ false, + ) + .await? + .expect("expected scan-and-repair fallback to find the rollout"); + app_server.shutdown().await?; + + assert_eq!(target.thread_id, thread_id); + Ok(()) + } + #[tokio::test] async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> { From 055c7a7c53d46517e95f24147980b4523da279db Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 5 Jun 2026 15:32:43 -0400 Subject: [PATCH 084/141] Speed up TUI startup by reusing plugin discovery (#26469) ## Summary TUI startup loads related plugin data from `hooks/list`, session MCP initialization, and plugin skill warmup. These paths repeated filesystem discovery and emitted the same plugin warnings, while `hooks/list` and account/model bootstrap ran serially. This change: - Reuses one immutable plugin load outcome across startup consumers. - Keys the cache only on plugin-relevant configuration. - Single-flights concurrent plugin loads and prevents invalidated loads from repopulating the cache. - Runs hook discovery and account/model bootstrap concurrently. - Preserves configuration-migration ordering, hook review behavior, and accurate startup telemetry. In 10 alternating release-build launches in the Ruff repository with the existing `~/.codex` configuration, median time to the first editable composer decreased from 833ms to 504ms. The branch was faster in 9 of 10 pairs, with a paired median improvement of 312ms. --- .../request_processors/catalog_processor.rs | 8 +- .../app-server/tests/suite/v2/hooks_list.rs | 78 ++++++++++++++++ codex-rs/core-plugins/src/manager.rs | 86 ++++++++++++++---- codex-rs/core-plugins/src/manager_tests.rs | 91 +++++++++++++++++++ codex-rs/tui/src/app.rs | 13 ++- codex-rs/tui/src/app_server_session.rs | 5 + ...external_agent_config_migration_startup.rs | 2 +- codex-rs/tui/src/lib.rs | 26 ++++++ codex-rs/tui/src/startup_hooks_review.rs | 31 +++++-- 9 files changed, 303 insertions(+), 37 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index bbba0bae62..9c0cc1c8b5 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -647,9 +647,11 @@ impl CatalogRequestProcessor { config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; let plugin_hooks = if plugins_enabled { let plugins_input = config.plugins_config_input(); - plugins_manager - .plugin_hooks_for_layer_stack(&config.config_layer_stack, &plugins_input) - .await + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + codex_core_plugins::PluginHookLoadOutcome { + hook_sources: plugin_outcome.effective_plugin_hook_sources(), + hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + } } else { codex_core_plugins::PluginHookLoadOutcome::default() }; diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index b7270f317e..1fd1340a6c 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -264,6 +264,84 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { Ok(()) } +#[tokio::test] +async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config( + codex_home.path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo plugin hook" + } + ] + } + ] + } +}"#, + )?; + let plugin_mcp_path = codex_home + .path() + .join("plugins/cache/test/demo/local/.mcp.json"); + std::fs::write( + &plugin_mcp_path, + r#"{ + "mcpServers": { + "plugin-server": { + "url": "http://127.0.0.1:1/mcp" + } + } +}"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hooks_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hooks_list_id)), + ) + .await??; + + std::fs::remove_file(plugin_mcp_path)?; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let _: ThreadStartResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??, + )?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_matching_notification("plugin MCP server starting", |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(serde_json::Value::as_str) + == Some("plugin-server") + }), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 08f668d130..845b8f3f32 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -57,8 +57,9 @@ use codex_config::apply_user_plugin_config_edits; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; -use codex_config::version_for_toml; use codex_core_skills::SkillMetadata; +use codex_core_skills::config_rules::SkillConfigRules; +use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_hooks::plugin_hook_declarations; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -403,7 +404,8 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + enabled_outcome_cache: RwLock, + enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, remote_sync_lock: Semaphore, @@ -413,10 +415,23 @@ pub struct PluginsManager { #[derive(Clone)] struct CachedPluginLoadOutcome { - config_version: String, + key: PluginLoadCacheKey, outcome: PluginLoadOutcome, } +#[derive(Default)] +struct EnabledOutcomeCache { + generation: u64, + outcome: Option, +} + +#[derive(Clone, PartialEq, Eq)] +struct PluginLoadCacheKey { + configured_plugins: HashMap, + skill_config_rules: SkillConfigRules, + remote_plugin_enabled: bool, +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_restriction_product(codex_home, Some(Product::Codex)) @@ -441,7 +456,8 @@ impl PluginsManager { ConfiguredMarketplaceUpgradeState::default(), ), non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), - cached_enabled_outcome: RwLock::new(None), + enabled_outcome_cache: RwLock::new(EnabledOutcomeCache::default()), + enabled_outcome_load_semaphore: Semaphore::new(/*permits*/ 1), remote_installed_plugins_cache: RwLock::new(None), remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), @@ -484,11 +500,23 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - let config_version = version_for_toml(&config.config_layer_stack.effective_config()); - if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) { + let cache_key = PluginLoadCacheKey { + configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), + skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), + remote_plugin_enabled: config.remote_plugin_enabled, + }; + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { return outcome; } + let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else { + warn!("plugin load semaphore closed"); + return PluginLoadOutcome::default(); + }; + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { + return outcome; + } + let cache_generation = self.enabled_outcome_cache_generation(); let outcome = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugin_configs(), @@ -498,14 +526,7 @@ impl PluginsManager { ) .await; log_plugin_load_errors(&outcome); - let mut cache = match self.cached_enabled_outcome.write() { - Ok(cache) => cache, - Err(err) => err.into_inner(), - }; - *cache = Some(CachedPluginLoadOutcome { - config_version, - outcome: outcome.clone(), - }); + self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone()); outcome } @@ -519,11 +540,12 @@ impl PluginsManager { } fn clear_enabled_outcome_cache(&self) { - let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() { + let mut cache = match self.enabled_outcome_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cached_enabled_outcome = None; + cache.generation = cache.generation.wrapping_add(1); + cache.outcome = None; } /// Load plugins for a config layer stack without touching the plugins cache. @@ -574,20 +596,44 @@ impl PluginsManager { .effective_plugin_skill_roots() } - fn cached_enabled_outcome(&self, config_version: &str) -> Option { - match self.cached_enabled_outcome.read() { + fn cached_enabled_outcome(&self, key: &PluginLoadCacheKey) -> Option { + match self.enabled_outcome_cache.read() { Ok(cache) => cache + .outcome .as_ref() - .filter(|cached| cached.config_version == config_version) + .filter(|cached| cached.key == *key) .map(|cached| cached.outcome.clone()), Err(err) => err .into_inner() + .outcome .as_ref() - .filter(|cached| cached.config_version == config_version) + .filter(|cached| cached.key == *key) .map(|cached| cached.outcome.clone()), } } + fn enabled_outcome_cache_generation(&self) -> u64 { + match self.enabled_outcome_cache.read() { + Ok(cache) => cache.generation, + Err(err) => err.into_inner().generation, + } + } + + fn cache_enabled_outcome_if_current( + &self, + generation: u64, + key: PluginLoadCacheKey, + outcome: PluginLoadOutcome, + ) { + let mut cache = match self.enabled_outcome_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + if cache.generation == generation { + cache.outcome = Some(CachedPluginLoadOutcome { key, outcome }); + } + } + fn remote_installed_plugin_configs(&self) -> HashMap { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d6c13b1fe0..f036ed40a5 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1300,6 +1300,97 @@ async fn load_plugins_returns_empty_when_feature_disabled() { assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn plugin_cache_ignores_unrelated_session_overrides() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs(); + let user_config: toml::Value = toml::from_str(&plugin_config_toml( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + )) + .expect("user config should parse"); + let stack = |session_config: &str| { + ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + }, + user_config.clone(), + ), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(session_config).expect("session config should parse"), + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build") + }; + let config = |session_config| { + PluginsConfigInput::new( + stack(session_config), + /*plugins_enabled*/ true, + /*remote_plugin_enabled*/ false, + "https://chatgpt.com".to_string(), + ) + }; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let first = manager + .plugins_for_config(&config(r#"model = "first""#)) + .await; + std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap(); + let second = manager + .plugins_for_config(&config(r#"model = "second""#)) + .await; + + assert_eq!(second, first); + assert_eq!(second.plugins()[0].mcp_servers.len(), 1); +} + +#[test] +fn plugin_cache_invalidation_rejects_stale_load_completion() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let cache_key = PluginLoadCacheKey { + configured_plugins: HashMap::new(), + skill_config_rules: SkillConfigRules::default(), + remote_plugin_enabled: false, + }; + let stale_generation = manager.enabled_outcome_cache_generation(); + + manager.clear_enabled_outcome_cache(); + manager.cache_enabled_outcome_if_current( + stale_generation, + cache_key.clone(), + PluginLoadOutcome::default(), + ); + + assert_eq!(manager.cached_enabled_outcome(&cache_key), None); +} + #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b1245db3ec..aae2cba954 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,7 @@ use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; +use crate::app_server_session::AppServerBootstrap; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::TurnPermissionsOverride; @@ -727,6 +728,8 @@ impl App { app_server_target: AppServerTarget, state_db: Option, environment_manager: Arc, + startup_elapsed_before_app: Duration, + startup_bootstrap: Option, startup_hooks_browser: Option, ) -> Result { use tokio_stream::StreamExt; @@ -774,9 +777,11 @@ impl App { }); } }; - let bootstrap_started_at = Instant::now(); - let bootstrap = app_server.bootstrap(&config).await?; - let bootstrap_ms = bootstrap_started_at.elapsed().as_millis(); + let bootstrap = match startup_bootstrap { + Some(bootstrap) => bootstrap, + None => app_server.bootstrap(&config).await?, + }; + let bootstrap_ms = bootstrap.duration.as_millis(); let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let remote_connection = crate::status::remote_connection::remote_connection_status_value( @@ -1085,7 +1090,7 @@ See the Codex keymap documentation for supported actions and examples." tui.frame_requester().schedule_frame(); tracing::info!( - duration_ms = %startup_started_at.elapsed().as_millis(), + duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(), bootstrap_ms = %bootstrap_ms, runtime_model_provider_ms = %runtime_model_provider_ms, thread_and_widget_ms = %thread_and_widget_ms, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 438ee21ca5..a04faa5261 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -127,6 +127,8 @@ use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use uuid::Uuid; const JSONRPC_INVALID_REQUEST: i64 = -32600; @@ -150,6 +152,7 @@ fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { /// fetched asynchronously after bootstrap returns so that the TUI can render /// its first frame without waiting for the rate-limit round-trip. pub(crate) struct AppServerBootstrap { + pub(crate) duration: Duration, pub(crate) account_email: Option, pub(crate) auth_mode: Option, pub(crate) status_account_display: Option, @@ -239,6 +242,7 @@ impl AppServerSession { } pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result { + let started_at = Instant::now(); let account = self.read_account().await?; let model_request_id = self.next_request_id(); let models: ModelListResponse = self @@ -314,6 +318,7 @@ impl AppServerSession { None => (None, None, None, None, FeedbackAudience::External, false), }; Ok(AppServerBootstrap { + duration: started_at.elapsed(), account_email, auth_mode, status_account_display, diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs index 3ec2ca3903..ce184303c2 100644 --- a/codex-rs/tui/src/external_agent_config_migration_startup.rs +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -25,7 +25,7 @@ pub(crate) enum ExternalAgentConfigMigrationStartupOutcome { ExitRequested, } -fn should_show_external_agent_config_migration_prompt( +pub(crate) fn should_show_external_agent_config_migration_prompt( config: &Config, entered_trust_nux: bool, ) -> bool { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ebaccf08fc..5e83c07461 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -71,6 +71,7 @@ use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; pub use token_usage::TokenUsage; use tracing::Level; use tracing::error; @@ -276,6 +277,7 @@ pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::startup_hooks_review::StartupHooksReviewOutcome; +use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; use crate::tui::Tui; pub use cli::Cli; @@ -1804,11 +1806,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::Resume(_) ); let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume; + let hooks_request_handle = app_server.request_handle(); + let hooks_cwd = config.cwd.to_path_buf(); + let startup_prefetch_started_at = Instant::now(); + let should_defer_bootstrap = + external_agent_config_migration_startup::should_show_external_agent_config_migration_prompt( + &config, + should_show_trust_screen_flag, + ); + let (startup_bootstrap, startup_hooks_entry) = if should_defer_bootstrap { + ( + None, + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd).await, + ) + } else { + let (bootstrap, entry) = tokio::join!( + app_server.bootstrap(&config), + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd), + ); + (Some(bootstrap?), entry) + }; + let startup_elapsed_before_app = startup_prefetch_started_at.elapsed(); let startup_hooks_browser = match maybe_run_startup_hooks_review( &mut app_server, &mut tui, &config, bypass_hook_trust_for_startup_review, + startup_hooks_entry, ) .await? { @@ -1833,6 +1857,8 @@ async fn run_ratatui_app( app_server_target, state_db, environment_manager, + startup_elapsed_before_app, + startup_bootstrap, startup_hooks_browser, ) .await; diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 67d6133a10..97c53f7144 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -31,7 +31,9 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::Tui; use crate::tui::TuiEvent; +use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::HooksListEntry; +use std::path::PathBuf; pub(crate) enum StartupHooksReviewOutcome { Continue, @@ -45,21 +47,32 @@ enum StartupHooksReviewSelection { ContinueWithoutTrusting, } +pub(crate) async fn load_startup_hooks_review_entry( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> HooksListEntry { + let response = match fetch_hooks_list(request_handle, cwd.clone()).await { + Ok(response) => response, + Err(err) => { + tracing::warn!("failed to load startup hook review state: {err:#}"); + return HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: Vec::new(), + }; + } + }; + hooks_list_entry_for_cwd(response, &cwd) +} + pub(crate) async fn maybe_run_startup_hooks_review( app_server: &mut AppServerSession, tui: &mut Tui, config: &Config, bypass_hook_trust: bool, + entry: HooksListEntry, ) -> Result { - let cwd = config.cwd.to_path_buf(); - let response = match fetch_hooks_list(app_server.request_handle(), cwd.clone()).await { - Ok(response) => response, - Err(err) => { - tracing::warn!("failed to load startup hook review state: {err:#}"); - return Ok(StartupHooksReviewOutcome::Continue); - } - }; - let entry = hooks_list_entry_for_cwd(response, &cwd); if !review_is_needed(bypass_hook_trust, &entry) { return Ok(StartupHooksReviewOutcome::Continue); } From bb7d19bc245b9661fbf2391a2d02d9d9139c4fee Mon Sep 17 00:00:00 2001 From: mpc-oai Date: Fri, 5 Jun 2026 14:40:31 -0500 Subject: [PATCH 085/141] Add JSON output for plugin subcommands (#26631) ## Summary - Follow-up to #25330 and #26417 - Add `--json` output for `codex plugin add` and `codex plugin remove` - Add `--json` output for `codex plugin marketplace add/list/upgrade/remove` - Keep existing human-readable output unchanged - Keep existing error handling/stderr behavior unchanged; `--json` changes successful stdout output only - Align marketplace add/remove JSON field names with the existing app-server protocol shape - Add CLI coverage for plugin and marketplace JSON outputs ## Validation - `just fmt` - `just fix -p codex-cli` - `just test -p codex-cli` --- codex-rs/cli/src/marketplace_cmd.rs | 186 +++++++++++++++++++++- codex-rs/cli/src/plugin_cmd.rs | 81 +++++++++- codex-rs/cli/tests/marketplace_add.rs | 37 +++++ codex-rs/cli/tests/marketplace_remove.rs | 29 ++++ codex-rs/cli/tests/marketplace_upgrade.rs | 25 +++ codex-rs/cli/tests/plugin_cli.rs | 89 +++++++++++ 6 files changed, 438 insertions(+), 9 deletions(-) diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 95dba06f17..fc4d9eef62 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -7,11 +7,14 @@ use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; use codex_core_plugins::marketplace::marketplace_root_dir; +use codex_core_plugins::marketplace_add::MarketplaceAddOutcome; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveOutcome; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; use std::collections::HashSet; use crate::plugin_cmd::configured_marketplace_snapshot_issues; @@ -32,7 +35,7 @@ enum MarketplaceSubcommand { Add(AddMarketplaceArgs), /// List plugin marketplaces Codex is currently considering and their roots. - List, + List(ListMarketplaceArgs), /// Refresh configured Git marketplace snapshots. /// @@ -64,6 +67,18 @@ struct AddMarketplaceArgs { action = clap::ArgAction::Append )] sparse_paths: Vec, + + /// Output add result as JSON. + #[arg(long = "json")] + json: bool, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace list")] +struct ListMarketplaceArgs { + /// Output marketplace list as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -75,6 +90,10 @@ struct UpgradeMarketplaceArgs { /// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: Option, + + /// Output upgrade result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -86,6 +105,10 @@ struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: String, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } impl MarketplaceCli { @@ -101,7 +124,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, - MarketplaceSubcommand::List => run_list(overrides).await?, + MarketplaceSubcommand::List(args) => run_list(overrides, args).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -115,6 +138,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { source, ref_name, sparse_paths, + json, } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; @@ -128,6 +152,12 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if outcome.already_added { println!( "Marketplace `{}` is already added from {}.", @@ -147,7 +177,25 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } -async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceAddOutput { + marketplace_name: String, + installed_root: String, + already_added: bool, +} + +impl JsonMarketplaceAddOutput { + fn from_outcome(outcome: MarketplaceAddOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root.as_path().display().to_string(), + already_added: outcome.already_added, + } + } +} + +async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceArgs) -> Result<()> { let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -191,6 +239,12 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { bail!("failed to load marketplace(s):\n{issue_lines}"); } let marketplaces = marketplace_listing.marketplaces; + if args.json { + let output = JsonMarketplaceListOutput::from_marketplaces(marketplaces); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { println!("No plugin marketplaces in scope."); return Ok(()); @@ -227,11 +281,48 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListOutput { + marketplaces: Vec, +} + +impl JsonMarketplaceListOutput { + fn from_marketplaces(marketplaces: Vec) -> Self { + let mut seen_roots = HashSet::new(); + let marketplaces = marketplaces + .into_iter() + .filter_map(|marketplace| { + let root = marketplace_root_dir(&marketplace.path).ok()?; + if !seen_roots.insert(root.clone()) { + return None; + } + Some(JsonMarketplaceListEntry { + name: marketplace.name, + root: root.display().to_string(), + }) + }) + .collect(); + + Self { marketplaces } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListEntry { + name: String, + root: String, +} + async fn run_upgrade( overrides: Vec<(String, toml::Value)>, args: UpgradeMarketplaceArgs, ) -> Result<()> { - let UpgradeMarketplaceArgs { marketplace_name } = args; + let UpgradeMarketplaceArgs { + marketplace_name, + json, + } = args; let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -241,11 +332,18 @@ async fn run_upgrade( let outcome = manager .upgrade_configured_marketplaces_for_config(&plugins_input, marketplace_name.as_deref()) .map_err(anyhow::Error::msg)?; - print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + if json { + print_upgrade_outcome_json(&outcome) + } else { + print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + } } async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { - let RemoveMarketplaceArgs { marketplace_name } = args; + let RemoveMarketplaceArgs { + marketplace_name, + json, + } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let outcome = remove_marketplace( codex_home.to_path_buf(), @@ -253,6 +351,12 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceRemoveOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!("Removed marketplace `{}`.", outcome.marketplace_name); if let Some(installed_root) = outcome.removed_installed_root { println!( @@ -264,6 +368,76 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceRemoveOutput { + marketplace_name: String, + installed_root: Option, +} + +impl JsonMarketplaceRemoveOutput { + fn from_outcome(outcome: MarketplaceRemoveOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome + .removed_installed_root + .map(|root| root.as_path().display().to_string()), + } + } +} + +fn print_upgrade_outcome_json(outcome: &PluginMarketplaceUpgradeOutcome) -> Result<()> { + for error in &outcome.errors { + eprintln!( + "Failed to upgrade marketplace `{}`: {}", + error.marketplace_name, error.message + ); + } + if !outcome.all_succeeded() { + bail!("{} upgrade failure(s) occurred.", outcome.errors.len()); + } + + let output = JsonMarketplaceUpgradeOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeOutput { + selected_marketplaces: Vec, + upgraded_roots: Vec, + errors: Vec, +} + +impl JsonMarketplaceUpgradeOutput { + fn from_outcome(outcome: &PluginMarketplaceUpgradeOutcome) -> Self { + Self { + selected_marketplaces: outcome.selected_marketplaces.clone(), + upgraded_roots: outcome + .upgraded_roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + errors: outcome + .errors + .iter() + .map(|error| JsonMarketplaceUpgradeError { + marketplace_name: error.marketplace_name.clone(), + message: error.message.clone(), + }) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeError { + marketplace_name: String, + message: String, +} + fn print_upgrade_outcome( outcome: &PluginMarketplaceUpgradeOutcome, marketplace_name: Option<&str>, diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index c850556e4d..7def7283c0 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -6,6 +6,7 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; +use codex_core_plugins::PluginInstallOutcome; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; @@ -73,6 +74,10 @@ pub struct AddPluginArgs { /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output install result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -107,6 +112,10 @@ pub struct RemovePluginArgs { /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } pub async fn run_plugin_add( @@ -118,11 +127,16 @@ pub async fn run_plugin_add( plugins_input, manager, } = load_plugin_command_context(overrides).await?; + let AddPluginArgs { + plugin, + marketplace_name, + json, + } = args; let PluginSelection { plugin_name, marketplace_name, .. - } = parse_plugin_selection(args.plugin, args.marketplace_name)?; + } = parse_plugin_selection(plugin, marketplace_name)?; let marketplace = find_marketplace_for_plugin( &manager, codex_home.as_path(), @@ -137,6 +151,12 @@ pub async fn run_plugin_add( }) .await?; + if json { + let output = JsonPluginAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!( "Added plugin `{}` from marketplace `{}`.", outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name @@ -149,6 +169,30 @@ pub async fn run_plugin_add( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginAddOutput { + plugin_id: String, + name: String, + marketplace_name: String, + version: String, + installed_path: String, + auth_policy: &'static str, +} + +impl JsonPluginAddOutput { + fn from_outcome(outcome: PluginInstallOutcome) -> Self { + Self { + plugin_id: outcome.plugin_id.as_key(), + name: outcome.plugin_id.plugin_name, + marketplace_name: outcome.plugin_id.marketplace_name, + version: outcome.plugin_version, + installed_path: outcome.installed_path.as_path().display().to_string(), + auth_policy: auth_policy_label(outcome.auth_policy), + } + } +} + pub async fn run_plugin_list( overrides: Vec<(String, toml::Value)>, args: ListPluginsArgs, @@ -449,9 +493,22 @@ pub async fn run_plugin_remove( args: RemovePluginArgs, ) -> Result<()> { let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; - let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?; + let RemovePluginArgs { + plugin, + marketplace_name, + json, + } = args; + let selection = parse_plugin_selection(plugin, marketplace_name)?; + + manager + .uninstall_plugin(selection.plugin_key.clone()) + .await?; + if json { + let output = JsonPluginRemoveOutput::from_selection(selection); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } - manager.uninstall_plugin(selection.plugin_key).await?; println!( "Removed plugin `{}` from marketplace `{}`.", selection.plugin_name, selection.marketplace_name @@ -460,6 +517,24 @@ pub async fn run_plugin_remove( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginRemoveOutput { + plugin_id: String, + name: String, + marketplace_name: String, +} + +impl JsonPluginRemoveOutput { + fn from_selection(selection: PluginSelection) -> Self { + Self { + plugin_id: selection.plugin_key, + name: selection.plugin_name, + marketplace_name: selection.marketplace_name, + } + } +} + struct PluginCommandContext { codex_home: PathBuf, plugins_input: PluginsConfigInput, diff --git a/codex-rs/cli/tests/marketplace_add.rs b/codex-rs/cli/tests/marketplace_add.rs index 5ab18e24c4..0439846a30 100644 --- a/codex-rs/cli/tests/marketplace_add.rs +++ b/codex-rs/cli/tests/marketplace_add.rs @@ -1,8 +1,10 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::AbsolutePathBuf; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -70,6 +72,41 @@ async fn marketplace_add_local_directory_source() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_add_json_prints_add_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + let source_parent = source.path().parent().unwrap(); + let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy()); + + let assert = codex_command(codex_home.path())? + .current_dir(source_parent) + .args([ + "plugin", + "marketplace", + "add", + source_arg.as_str(), + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let expected_installed_root = AbsolutePathBuf::try_from(source.path().canonicalize()?)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": expected_installed_root.as_path().display().to_string(), + "alreadyAdded": false, + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs index 5c8c7a1f91..b124574106 100644 --- a/codex-rs/cli/tests/marketplace_remove.rs +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -2,7 +2,10 @@ use anyhow::Result; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -54,6 +57,32 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_remove_json_prints_remove_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + let normalized_installed_root = canonicalize_existing_preserving_symlinks(&installed_root)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": normalized_installed_root.display().to_string(), + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_upgrade.rs b/codex-rs/cli/tests/marketplace_upgrade.rs index 268d75358e..a2cf11f4af 100644 --- a/codex-rs/cli/tests/marketplace_upgrade.rs +++ b/codex-rs/cli/tests/marketplace_upgrade.rs @@ -1,5 +1,7 @@ use anyhow::Result; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -22,6 +24,29 @@ async fn marketplace_upgrade_runs_under_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_upgrade_json_prints_upgrade_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "upgrade", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "selectedMarketplaces": [], + "upgradedRoots": [], + "errors": [], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 2625e0b68b..cda79cf9a1 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -338,6 +338,32 @@ async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": source.path().display().to_string(), + }, + ], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> { let codex_home = TempDir::new()?; @@ -802,6 +828,69 @@ async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_add_json_prints_install_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let installed_path = codex_home.path().join("plugins/cache/debug/sample/1.2.3"); + let normalized_installed_path = canonicalize_existing_preserving_symlinks(&installed_path)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installedPath": normalized_installed_path.display().to_string(), + "authPolicy": "ON_INSTALL", + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_remove_json_prints_remove_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args([ + "plugin", + "remove", + "sample", + "--marketplace", + "debug", + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + }) + ); + + Ok(()) +} + #[tokio::test] async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { let (codex_home, source) = setup_unconfigured_local_marketplace()?; From 679cc084451e2111ad946d0befe583b79e486ca9 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Jun 2026 14:09:40 -0700 Subject: [PATCH 086/141] [codex] Bound WSL local curated discovery (#26669) ## Context The installed-app suggestion expansion added in #24996 reads plugin details for trusted file-backed marketplace candidates because the list response does not include app ids. On Windows-backed WSL mounts, the local `openai-curated` checkout lives under `$CODEX_HOME/.tmp/plugins`, and those per-plugin detail reads can be very slow. Remote curated already has cached app ids, so it does not need the same local filesystem traversal. ## Summary - Keep only the WSL Windows-backed local `openai-curated` checkout on the legacy fallback/configured discovery path. - Preserve installed-app expansion for non-WSL file-backed marketplaces and remote curated. - Add focused tests for the WSL local curated path predicate. ## Test - `just test -p codex-core-plugins discoverable` - `just test -p codex-core plugins::discoverable::tests` --- codex-rs/core-plugins/src/discoverable.rs | 43 +++++++++++++ .../core-plugins/src/discoverable_tests.rs | 61 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 codex-rs/core-plugins/src/discoverable_tests.rs diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index ccf093d816..f376d099ce 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -4,6 +4,8 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; use std::collections::HashSet; +use std::path::Component; +use std::path::Path; use tracing::warn; use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; @@ -53,6 +55,9 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ REMOTE_GLOBAL_MARKETPLACE_NAME, ]; +const OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX: &str = + ".tmp/plugins/.agents/plugins/marketplace.json"; + #[derive(Debug, Clone)] pub struct ToolSuggestPluginDiscoveryInput { pub plugins: PluginsConfigInput, @@ -108,6 +113,10 @@ impl PluginsManager { { continue; } + let use_legacy_local_curated_filter = should_use_legacy_local_curated_discovery_filter( + &marketplace_name, + marketplace.path.as_path(), + ); let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST .contains(&marketplace_name.as_str()); @@ -123,6 +132,13 @@ impl PluginsManager { continue; } + // On Windows-backed WSL mounts, keep local curated discovery bounded to the + // legacy fallback/configured set instead of reading every plugin detail for app + // ids. Remote curated has cached app ids and still expands by installed apps. + if use_legacy_local_curated_filter && !is_configured_plugin && !is_fallback_plugin { + continue; + } + let plugin_id = plugin.id.clone(); match self @@ -216,3 +232,30 @@ impl PluginsManager { Ok(discoverable_plugins) } } + +fn should_use_legacy_local_curated_discovery_filter( + marketplace_name: &str, + marketplace_path: &Path, +) -> bool { + marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + && is_wsl_windows_drive_path(marketplace_path) + && marketplace_path.ends_with(Path::new(OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX)) +} + +fn is_wsl_windows_drive_path(path: &Path) -> bool { + let mut components = path.components(); + if components.next() != Some(Component::RootDir) { + return false; + } + if components.next().and_then(|part| part.as_os_str().to_str()) != Some("mnt") { + return false; + } + let Some(drive) = components.next().and_then(|part| part.as_os_str().to_str()) else { + return false; + }; + drive.len() == 1 && drive.as_bytes()[0].is_ascii_alphabetic() +} + +#[cfg(test)] +#[path = "discoverable_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs new file mode 100644 index 0000000000..240fd86f17 --- /dev/null +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -0,0 +1,61 @@ +use std::path::Path; + +use super::is_wsl_windows_drive_path; +use super::should_use_legacy_local_curated_discovery_filter; +use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; + +#[test] +fn legacy_local_curated_filter_matches_wsl_windows_backed_curated_checkout() { + let marketplace_path = + Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + marketplace_path, + )); +} + +#[test] +fn legacy_local_curated_filter_does_not_match_native_wsl_curated_checkout() { + let marketplace_path = + Path::new("/home/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + marketplace_path, + )); +} + +#[test] +fn legacy_local_curated_filter_does_not_match_other_wsl_marketplaces() { + let other_marketplace_path = Path::new( + "/mnt/c/Users/user/.codex/.tmp/marketplaces/other/.agents/plugins/marketplace.json", + ); + let local_curated_marketplace_path = + Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + other_marketplace_path, + )); + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_BUNDLED_MARKETPLACE_NAME, + local_curated_marketplace_path, + )); +} + +#[test] +fn wsl_windows_drive_path_matches_only_mnt_drive_paths() { + assert!(is_wsl_windows_drive_path(Path::new( + "/mnt/c/Users/user/.codex/.tmp/plugins", + ))); + assert!(is_wsl_windows_drive_path(Path::new("/mnt/Z/tmp"))); + assert!(!is_wsl_windows_drive_path(Path::new("/home/user/.codex"))); + assert!(!is_wsl_windows_drive_path(Path::new( + "/mnt/codex/Users/user/.codex", + ))); + assert!(!is_wsl_windows_drive_path(Path::new( + "/media/c/Users/user/.codex", + ))); +} From 479a14cf592d5e0ca319942e6ba833ebaa1274e2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 14:17:30 -0700 Subject: [PATCH 087/141] [2 of 2] Finish moving goal runtime to extension (#26548) ## Stack 1. [#26547](https://github.com/openai/codex/pull/26547) - [1 of 2] Align goal extension with core behavior 2. [#26548](https://github.com/openai/codex/pull/26548) - [2 of 2] Move goal runtime to extension ## Why This PR completes the switch of the goal behavior to the extension-backed runtime and removes the old core goal implementation. ## What Changed - Installs the goal extension for app-server `ThreadManager` sessions. - Routes app-server thread goal `get`, `set`, and `clear` through `GoalService`. - Uses thread-idle lifecycle emission after goal resume and snapshot ordering so the extension can decide whether to continue the goal. - Forwards extension goal updates through a FIFO async app-server notification path so backpressure does not drop them or reorder updates. - Keeps review turns from enabling goal runtime behavior. - Plans extension tools before dynamic tools so built-in goal tool names keep their old precedence when goals are enabled. - Removes the old core goal runtime, core goal tool handlers, and core goal tool specs. - Updates tests that were coupled to the core-owned goal runtime while leaving the legacy `` compatibility path in core for old threads. - Removes the stale cargo-shear ignore now that `codex-goal-extension` is used by the workspace. - Keeps realtime event matching exhaustive after removing the old goal-specific realtime text path. ## Validation - Ran manual `/goal` runs in TUI. Validated time accounting matched wall-clock time and goal lifecycle state transitions. --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 - codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/extensions.rs | 156 +- codex-rs/app-server/src/mcp_refresh.rs | 3 + codex-rs/app-server/src/message_processor.rs | 8 +- codex-rs/app-server/src/outgoing_message.rs | 10 - codex-rs/app-server/src/request_processors.rs | 2 - .../thread_goal_processor.rs | 301 +-- .../request_processors/thread_lifecycle.rs | 33 +- codex-rs/app-server/src/thread_state.rs | 39 +- codex-rs/core/src/codex_thread.rs | 48 +- .../context/contextual_user_message_tests.rs | 14 +- codex-rs/core/src/event_mapping_tests.rs | 4 +- codex-rs/core/src/goals.rs | 1622 ----------------- codex-rs/core/src/lib.rs | 3 - codex-rs/core/src/session/review.rs | 2 - codex-rs/core/src/session/session.rs | 3 - codex-rs/core/src/session/tests.rs | 1182 +----------- codex-rs/core/src/session/turn.rs | 30 - codex-rs/core/src/session/turn_context.rs | 10 - codex-rs/core/src/tasks/mod.rs | 42 - codex-rs/core/src/thread_manager.rs | 3 - codex-rs/core/src/thread_manager_tests.rs | 101 - codex-rs/core/src/tools/handlers/goal.rs | 158 -- .../src/tools/handlers/goal/create_goal.rs | 78 - .../core/src/tools/handlers/goal/get_goal.rs | 51 - .../src/tools/handlers/goal/update_goal.rs | 89 - codex-rs/core/src/tools/handlers/goal_spec.rs | 120 -- codex-rs/core/src/tools/handlers/mod.rs | 5 - codex-rs/core/src/tools/registry.rs | 16 +- codex-rs/core/src/tools/spec_plan.rs | 18 +- codex-rs/core/src/tools/spec_plan_tests.rs | 31 +- codex-rs/core/tests/suite/prompt_caching.rs | 3 - 34 files changed, 280 insertions(+), 3908 deletions(-) delete mode 100644 codex-rs/core/src/goals.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal/create_goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal/get_goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal/update_goal.rs delete mode 100644 codex-rs/core/src/tools/handlers/goal_spec.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b899222384..a61df434f1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1926,6 +1926,7 @@ dependencies = [ "codex-file-search", "codex-file-watcher", "codex-git-utils", + "codex-goal-extension", "codex-guardian", "codex-hooks", "codex-image-generation-extension", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5d6d116049..5fe0104321 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -483,7 +483,6 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-goal-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 37cdf219bf..4bdc430a1a 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -41,6 +41,7 @@ codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } +codex-goal-extension = { workspace = true } codex-guardian = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 7b2673ca06..53f19997cd 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::Weak; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_core::NewThread; use codex_core::StartThreadOptions; @@ -12,23 +13,40 @@ use codex_extension_api::AgentSpawner; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_goal_extension::GoalService; use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_rollout::state_db::StateDbHandle; use crate::outgoing_message::OutgoingMessageSender; +use crate::thread_state::ThreadListenerCommand; +use crate::thread_state::ThreadStateManager; pub(crate) fn thread_extensions( guardian_agent_spawner: S, event_sink: Arc, auth_manager: Arc, + state_db: Option, + thread_manager: Weak, + goal_service: Arc, ) -> Arc> where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); + if let Some(state_db) = state_db { + codex_goal_extension::install_with_backend( + &mut builder, + state_db, + codex_otel::global(), + thread_manager, + goal_service, + |config: &Config| config.features.enabled(codex_features::Feature::Goals), + ); + } codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); codex_web_search_extension::install(&mut builder, auth_manager.clone()); @@ -38,26 +56,53 @@ where pub(crate) fn app_server_extension_event_sink( outgoing: Arc, + thread_state_manager: ThreadStateManager, ) -> Arc { - Arc::new(AppServerExtensionEventSink { outgoing }) + Arc::new(AppServerExtensionEventSink { + outgoing, + thread_state_manager, + }) } struct AppServerExtensionEventSink { outgoing: Arc, + thread_state_manager: ThreadStateManager, } impl ExtensionEventSink for AppServerExtensionEventSink { fn emit(&self, event: Event) { match event.msg { EventMsg::ThreadGoalUpdated(thread_goal_event) => { - self.outgoing - .try_send_server_notification(ServerNotification::ThreadGoalUpdated( - ThreadGoalUpdatedNotification { - thread_id: thread_goal_event.thread_id.to_string(), - turn_id: thread_goal_event.turn_id, - goal: thread_goal_event.goal.into(), - }, - )); + let thread_id = thread_goal_event.thread_id; + let turn_id = thread_goal_event.turn_id; + let goal: ThreadGoal = thread_goal_event.goal.into(); + if let Some(listener_command_tx) = self + .thread_state_manager + .current_listener_command_tx(thread_id) + { + let command = ThreadListenerCommand::EmitThreadGoalUpdated { + turn_id: turn_id.clone(), + goal: goal.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + tracing::warn!( + "failed to enqueue extension goal update for {thread_id}: listener command channel is closed" + ); + } + let outgoing = Arc::clone(&self.outgoing); + tokio::spawn(async move { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id, + goal, + }, + )) + .await; + }); } msg => { tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event"); @@ -89,10 +134,7 @@ mod tests { use std::time::Duration; use codex_analytics::AnalyticsEventsClient; - use codex_app_server_protocol::ServerNotification; - use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal; - use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus; - use codex_protocol::protocol::ThreadGoal; + use codex_protocol::protocol::ThreadGoal as CoreThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use pretty_assertions::assert_eq; @@ -100,25 +142,61 @@ mod tests { use tokio::time::timeout; use super::*; - use crate::outgoing_message::OutgoingEnvelope; - use crate::outgoing_message::OutgoingMessage; #[tokio::test] - async fn app_server_event_sink_forwards_thread_goal_updates() { - let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4); + async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_and_clears() { + let (outgoing_tx, _outgoing_rx) = mpsc::channel(4); let outgoing = Arc::new(OutgoingMessageSender::new( outgoing_tx, AnalyticsEventsClient::disabled(), )); - let sink = app_server_extension_event_sink(outgoing); + let thread_state_manager = ThreadStateManager::new(); let thread_id = ThreadId::default(); + let (listener_command_tx, mut listener_command_rx) = mpsc::unbounded_channel(); + thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx.clone()); + let sink = app_server_extension_event_sink(outgoing, thread_state_manager); - sink.emit(Event { - id: "call-1".to_string(), + for turn_id in ["turn-1", "turn-2"] { + sink.emit(thread_goal_updated_event(thread_id, turn_id)); + } + listener_command_tx + .send(ThreadListenerCommand::EmitThreadGoalCleared) + .expect("listener command channel should be open"); + + let mut observed = Vec::new(); + for _ in 0..3 { + let command = timeout(Duration::from_secs(1), listener_command_rx.recv()) + .await + .expect("timed out waiting for listener command") + .expect("listener command channel closed unexpectedly"); + match command { + ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, .. } => { + observed.push(turn_id.expect("extension goal updates should include turn ids")); + } + ThreadListenerCommand::EmitThreadGoalCleared => { + observed.push("cleared".to_string()) + } + _ => panic!("unexpected listener command"), + } + } + + assert_eq!( + vec![ + "turn-1".to_string(), + "turn-2".to_string(), + "cleared".to_string() + ], + observed + ); + } + + fn thread_goal_updated_event(thread_id: ThreadId, turn_id: &str) -> Event { + Event { + id: turn_id.to_string(), msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { thread_id, - turn_id: Some("turn-1".to_string()), - goal: ThreadGoal { + turn_id: Some(turn_id.to_string()), + goal: CoreThreadGoal { thread_id, objective: "wire extension events".to_string(), status: ThreadGoalStatus::Active, @@ -129,38 +207,6 @@ mod tests { updated_at: 8, }, }), - }); - - let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv()) - .await - .expect("timed out waiting for forwarded extension event") - .expect("outgoing channel closed unexpectedly"); - let OutgoingEnvelope::Broadcast { message } = envelope else { - panic!("expected broadcast notification"); - }; - let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated( - notification, - )) = message - else { - panic!("expected thread goal updated notification"); - }; - - assert_eq!( - ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), - turn_id: Some("turn-1".to_string()), - goal: AppServerThreadGoal { - thread_id: thread_id.to_string(), - objective: "wire extension events".to_string(), - status: AppServerThreadGoalStatus::Active, - token_budget: Some(123), - tokens_used: 45, - time_used_seconds: 6, - created_at: 7, - updated_at: 8, - }, - }, - notification - ); + } } } diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 0996c409bc..8c72533ba5 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -191,6 +191,9 @@ mod tests { guardian_agent_spawner(thread_manager.clone()), Arc::new(NoopExtensionEventSink), auth_manager.clone(), + Some(state_db.clone()), + thread_manager.clone(), + Arc::new(codex_goal_extension::GoalService::new()), ), /*analytics_events_client*/ None, Arc::clone(&thread_store), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 76f39663a0..b7e0ff258d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -70,6 +70,7 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; +use codex_goal_extension::GoalService; use codex_login::AuthManager; use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; @@ -305,6 +306,7 @@ impl MessageProcessor { // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); let environment_manager_for_requests = Arc::clone(&environment_manager); + let goal_service = Arc::new(GoalService::new()); let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( config.as_ref(), @@ -313,8 +315,11 @@ impl MessageProcessor { environment_manager, thread_extensions( guardian_agent_spawner(thread_manager.clone()), - app_server_extension_event_sink(outgoing.clone()), + app_server_extension_event_sink(outgoing.clone(), thread_state_manager.clone()), auth_manager.clone(), + state_db.clone(), + thread_manager.clone(), + Arc::clone(&goal_service), ), Some(analytics_events_client.clone()), Arc::clone(&thread_store), @@ -416,6 +421,7 @@ impl MessageProcessor { Arc::clone(&config), thread_state_manager.clone(), state_db.clone(), + Arc::clone(&goal_service), ); let thread_processor = ThreadRequestProcessor::new( auth_manager.clone(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2e67e23398..ba75d0afd3 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -555,16 +555,6 @@ impl OutgoingMessageSender { .await; } - pub(crate) fn try_send_server_notification(&self, notification: ServerNotification) { - tracing::trace!("app-server event: {notification}"); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); - if let Err(err) = self.sender.try_send(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) { - warn!("failed to send server notification to client without waiting: {err:?}"); - } - } - pub(crate) async fn send_server_notification_to_connections( &self, connection_ids: &[ConnectionId], diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index bfcf328c43..0266cb9d95 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -279,8 +279,6 @@ use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::CodexThreadSettingsOverrides; -use codex_core::ExternalGoalPreviousStatus; -use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; use codex_core::NewThread; #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index c18806ea03..2f40ce6602 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -1,5 +1,9 @@ use super::*; -use codex_protocol::protocol::validate_thread_goal_objective; +use codex_goal_extension::GoalObjectiveUpdate; +use codex_goal_extension::GoalService; +use codex_goal_extension::GoalServiceError; +use codex_goal_extension::GoalSetRequest; +use codex_goal_extension::GoalTokenBudgetUpdate; #[derive(Clone)] pub(crate) struct ThreadGoalRequestProcessor { @@ -8,6 +12,7 @@ pub(crate) struct ThreadGoalRequestProcessor { config: Arc, thread_state_manager: ThreadStateManager, state_db: Option, + goal_service: Arc, } impl ThreadGoalRequestProcessor { @@ -17,6 +22,7 @@ impl ThreadGoalRequestProcessor { config: Arc, thread_state_manager: ThreadStateManager, state_db: Option, + goal_service: Arc, ) -> Self { Self { thread_manager, @@ -24,6 +30,7 @@ impl ThreadGoalRequestProcessor { config, thread_state_manager, state_db, + goal_service, } } @@ -66,10 +73,8 @@ impl ThreadGoalRequestProcessor { } self.emit_thread_goal_snapshot(thread_id).await; // App-server owns resume response and snapshot ordering, so wait until - // those are sent before letting core start goal continuation. - if let Err(err) = thread.continue_active_goal_if_idle().await { - tracing::warn!("failed to continue active goal after resume: {err}"); - } + // those are sent before letting extensions react to the idle thread. + thread.emit_thread_idle_lifecycle_if_idle().await; } pub(crate) async fn pending_resume_goal_state( @@ -100,140 +105,36 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let rollout_path = match running_thread.as_ref() { - Some(thread) => thread.rollout_path().ok_or_else(|| { - invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - )) - })?, - None => codex_rollout::find_thread_path_by_id_str( - &self.config.codex_home, - &thread_id.to_string(), - self.state_db.as_deref(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, - }; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; + self.reconcile_thread_goal_rollout(thread_id, &state_db) + .await?; let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let status = params.status.map(thread_goal_status_to_state); - let objective = params.objective.as_deref().map(str::trim); + let status = params.status.map(ThreadGoalStatus::to_core); + let objective = params.objective.as_deref(); - if let Some(objective) = objective { - validate_thread_goal_objective(objective).map_err(invalid_request)?; - } - if objective.is_some() || params.token_budget.is_some() { - validate_goal_budget(params.token_budget.flatten()).map_err(invalid_request)?; - } - - if let Some(thread) = running_thread.as_ref() { - thread.prepare_external_goal_mutation().await; - } - - let should_set_thread_preview = objective.is_some(); - let (goal, previous_status) = (if let Some(objective) = objective { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| invalid_request(err.to_string()))?; - if let Some(goal) = existing_goal.as_ref() { - let previous_status = ExternalGoalPreviousStatus::from(goal); - state_db - .thread_goals() - .update_thread_goal( - thread_id, - codex_state::GoalUpdate { - objective: Some(objective.to_string()), - status, - token_budget: params.token_budget, - expected_goal_id: Some(goal.goal_id.clone()), - }, - ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {thread_id}: no goal exists" - ) - }) - }) - .map(|goal| (goal, previous_status)) - } else { - let previous_status = ExternalGoalPreviousStatus::NewGoal; - state_db - .thread_goals() - .replace_thread_goal( - thread_id, - objective, - status.unwrap_or(codex_state::ThreadGoalStatus::Active), - params.token_budget.flatten(), - ) - .await - .map(|goal| (goal, previous_status)) - } - } else { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| invalid_request(err.to_string()))?; - let Some(existing_goal) = existing_goal else { - return Err(invalid_request(format!( - "cannot update goal for thread {thread_id}: no goal exists" - ))); - }; - let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); - state_db - .thread_goals() - .update_thread_goal( + let outcome = self + .goal_service + .set_thread_goal( + &state_db, + GoalSetRequest { thread_id, - codex_state::GoalUpdate { - objective: None, - status, - token_budget: params.token_budget, - expected_goal_id: None, + objective: objective + .map(GoalObjectiveUpdate::Set) + .unwrap_or(GoalObjectiveUpdate::Keep), + status, + token_budget: match params.token_budget { + Some(token_budget) => GoalTokenBudgetUpdate::Set(token_budget), + None => GoalTokenBudgetUpdate::Keep, }, - ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") - }) - }) - .map(|goal| (goal, previous_status)) - }) - .map_err(|err| invalid_request(err.to_string()))?; - if should_set_thread_preview - && let Err(err) = state_db - .set_thread_preview_if_empty(thread_id, goal.objective.as_str()) - .await - { - warn!("failed to set empty thread preview from goal objective for {thread_id}: {err}"); - } - let external_goal_set = ExternalGoalSet { - goal: goal.clone(), - previous_status, - }; - let goal = api_thread_goal_from_state(goal); + }, + ) + .await + .map_err(goal_service_error)?; + let goal = ThreadGoal::from(outcome.goal.clone()); self.outgoing .send_response( request_id.clone(), @@ -242,9 +143,7 @@ impl ThreadGoalRequestProcessor { .await; self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; - if let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_set(external_goal_set).await; - } + outcome.apply_runtime_effects(&self.goal_service).await; Ok(()) } @@ -258,12 +157,12 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(thread_id) + let goal = self + .goal_service + .get_thread_goal(&state_db, thread_id) .await - .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? - .map(api_thread_goal_from_state); + .map_err(goal_service_error)? + .map(ThreadGoal::from); Ok(ThreadGoalGetResponse { goal }) } @@ -278,53 +177,19 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let rollout_path = match running_thread.as_ref() { - Some(thread) => thread.rollout_path().ok_or_else(|| { - invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - )) - })?, - None => codex_rollout::find_thread_path_by_id_str( - &self.config.codex_home, - &thread_id.to_string(), - self.state_db.as_deref(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, - }; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - if let Some(thread) = running_thread.as_ref() { - thread.prepare_external_goal_mutation().await; - } + self.reconcile_thread_goal_rollout(thread_id, &state_db) + .await?; let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let cleared = state_db - .thread_goals() - .delete_thread_goal(thread_id) + let cleared = self + .goal_service + .clear_thread_goal(&state_db, thread_id) .await - .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; - - if cleared && let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_clear().await; - } + .map_err(goal_service_error)?; self.outgoing .send_response(request_id, ThreadGoalClearResponse { cleared }) @@ -367,6 +232,42 @@ impl ThreadGoalRequestProcessor { .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) } + async fn reconcile_thread_goal_rollout( + &self, + thread_id: ThreadId, + state_db: &StateDbHandle, + ) -> Result<(), JSONRPCErrorError> { + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => codex_rollout::find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + }; + reconcile_rollout( + Some(state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + Ok(()) + } + async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { let state_db = match self.state_db_for_materialized_thread(thread_id).await { Ok(state_db) => state_db, @@ -405,6 +306,7 @@ impl ThreadGoalRequestProcessor { ) { if let Some(listener_command_tx) = listener_command_tx { let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated { + turn_id: None, goal: goal.clone(), }; if listener_command_tx.send(command).is_ok() { @@ -449,27 +351,20 @@ impl ThreadGoalRequestProcessor { } } -fn validate_goal_budget(value: Option) -> Result<(), String> { - if let Some(value) = value - && value <= 0 - { - return Err("goal budgets must be positive when provided".to_string()); - } - Ok(()) -} - -fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { - match status { - ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, - ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, - ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, - ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, - ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, - ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, +pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: api_thread_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), } } -fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { +fn api_thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, @@ -480,16 +375,10 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa } } -pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { - ThreadGoal { - thread_id: goal.thread_id.to_string(), - objective: goal.objective, - status: thread_goal_status_from_state(goal.status), - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at.timestamp(), - updated_at: goal.updated_at.timestamp(), +fn goal_service_error(err: GoalServiceError) -> JSONRPCErrorError { + match err { + GoalServiceError::InvalidRequest(message) => invalid_request(message), + GoalServiceError::Internal(message) => internal_error(message), } } diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index f72cfb5a62..8bfb282ace 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -244,12 +244,22 @@ pub(super) async fn ensure_listener_task_running( if thread_state.listener_matches(&conversation) { return Ok(()); } - thread_state.set_listener( + let (listener_command_rx, listener_generation) = thread_state.set_listener( cancel_tx, &conversation, watch_registration, thread_settings_baseline, - ) + ); + let Some(listener_command_tx) = thread_state.listener_command_tx() else { + tracing::warn!( + "thread listener command sender missing immediately after listener registration" + ); + return Ok(()); + }; + listener_task_context + .thread_state_manager + .register_listener_command_tx(conversation_id, listener_command_tx); + (listener_command_rx, listener_generation) }; let ListenerTaskContext { outgoing, @@ -378,6 +388,7 @@ pub(super) async fn ensure_listener_task_running( let mut thread_state = thread_state.lock().await; if thread_state.listener_generation == listener_generation { + thread_state_manager.unregister_listener_command_tx(conversation_id); thread_state.clear_listener(); } }); @@ -471,12 +482,12 @@ pub(super) async fn handle_thread_listener_command( ) .await; } - ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, goal } => { outgoing .send_server_notification(ServerNotification::ThreadGoalUpdated( ThreadGoalUpdatedNotification { thread_id: conversation_id.to_string(), - turn_id: None, + turn_id, goal, }, )) @@ -616,12 +627,6 @@ pub(super) async fn handle_pending_thread_resume_request( } } - if pending.emit_thread_goal_update - && let Err(err) = conversation.apply_goal_resume_runtime_effects().await - { - tracing::warn!("failed to apply goal resume runtime effects: {err}"); - } - let ThreadConfigSnapshot { model, model_provider_id, @@ -691,11 +696,9 @@ pub(super) async fn handle_pending_thread_resume_request( .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; // App-server owns resume response and snapshot ordering, so wait until - // replay completes before letting core start goal continuation. - if pending.emit_thread_goal_update - && let Err(err) = conversation.continue_active_goal_if_idle().await - { - tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + // replay completes before letting extensions react to the idle thread. + if pending.emit_thread_goal_update { + conversation.emit_thread_idle_lifecycle_if_idle().await; } } diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 2d932f0326..6d2b48a4c8 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -17,6 +17,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::Weak; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -44,8 +45,9 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) enum ThreadListenerCommand { // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates. SendThreadResumeResponse(Box), - // EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses. + // EmitThreadGoalUpdated is used to order goal updates with running-thread resume responses and goal clears. EmitThreadGoalUpdated { + turn_id: Option, goal: ThreadGoal, }, // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses. @@ -284,6 +286,10 @@ pub(crate) struct ConnectionCapabilities { #[derive(Clone, Default)] pub(crate) struct ThreadStateManager { state: Arc>, + // Extension event sinks are synchronous, so they need an await-free way to + // enqueue work on the active per-thread listener. + listener_commands: + Arc>>>, } impl ThreadStateManager { @@ -337,6 +343,35 @@ impl ThreadStateManager { state.threads.entry(thread_id).or_default().state.clone() } + pub(crate) fn current_listener_command_tx( + &self, + thread_id: ThreadId, + ) -> Option> { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&thread_id) + .cloned() + } + + pub(crate) fn register_listener_command_tx( + &self, + thread_id: ThreadId, + tx: mpsc::UnboundedSender, + ) { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(thread_id, tx); + } + + pub(crate) fn unregister_listener_command_tx(&self, thread_id: ThreadId) { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&thread_id); + } + pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) { let thread_state = { let mut state = self.state.lock().await; @@ -350,6 +385,7 @@ impl ThreadStateManager { }); thread_state }; + self.unregister_listener_command_tx(thread_id); if let Some(thread_state) = thread_state { let mut thread_state = thread_state.lock().await; @@ -375,6 +411,7 @@ impl ThreadStateManager { }; for (thread_id, thread_state) in thread_states { + self.unregister_listener_command_tx(thread_id); let mut thread_state = thread_state.lock().await; tracing::debug!( thread_id = %thread_id, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index d87b65e8ae..b3f9cb0e2e 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,7 +1,5 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; -use crate::goals::ExternalGoalSet; -use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -205,51 +203,11 @@ impl CodexThread { } } - pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + pub async fn emit_thread_idle_lifecycle_if_idle(&self) { self.codex .session - .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) - .await - } - - pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { - self.codex - .session - .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await - } - - pub async fn prepare_external_goal_mutation(&self) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) - .await - { - tracing::warn!("failed to prepare external goal mutation: {err}"); - } - } - - pub async fn apply_external_goal_set(&self, external_set: ExternalGoalSet) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { external_set }) - .await - { - tracing::warn!("failed to apply external goal status runtime effects: {err}"); - } - } - - pub async fn apply_external_goal_clear(&self) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) - .await - { - tracing::warn!("failed to apply external goal clear runtime effects: {err}"); - } + .emit_thread_idle_lifecycle_if_idle() + .await; } #[doc(hidden)] diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index dc7cb0f534..c3f360772a 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -33,14 +33,14 @@ fn detects_subagent_notification_fragment_case_insensitively() { #[test] fn detects_internal_model_context_fragment() { let text = InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", ) .render(); assert_eq!( text, - "\nContinue working toward the active thread goal.\n" + "\nInternal steering.\n" ); assert!(is_contextual_user_fragment(&ContentItem::InputText { text @@ -65,7 +65,7 @@ fn does_not_hide_arbitrary_context_tags() { #[test] fn rejects_invalid_internal_model_context_source() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { - text: "\nbody\n" + text: "\nbody\n" .to_string(), })); } @@ -73,13 +73,13 @@ fn rejects_invalid_internal_model_context_source() { #[test] fn contextual_user_fragment_is_dyn_compatible() { let fragment: Box = Box::new(InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", )); assert_eq!( fragment.render(), - "\nContinue working toward the active thread goal.\n" + "\nInternal steering.\n" ); } diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 4cf1b72d5d..5f2ed66830 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -324,8 +324,8 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { role: "user".to_string(), content: vec![ContentItem::InputText { text: InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", ) .render(), }], diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs deleted file mode 100644 index 6e0663655c..0000000000 --- a/codex-rs/core/src/goals.rs +++ /dev/null @@ -1,1622 +0,0 @@ -//! Core support for persisted thread goals. -//! -//! This module bridges core sessions and the state-db goal table. It validates -//! goal mutations, converts between state and protocol shapes, emits goal-update -//! events, and owns helper hooks used by goal lifecycle behavior. - -use crate::StateDbHandle; -use crate::context::ContextualUserFragment; -use crate::context::InternalContextSource; -use crate::context::InternalModelContextFragment; -use crate::session::TurnInput; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; -use crate::state::ActiveTurn; -use crate::state::TurnState; -use crate::tasks::RegularTask; -use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; -use anyhow::Context; -use codex_features::Feature; -use codex_otel::GOAL_BLOCKED_METRIC; -use codex_otel::GOAL_BUDGET_LIMITED_METRIC; -use codex_otel::GOAL_COMPLETED_METRIC; -use codex_otel::GOAL_CREATED_METRIC; -use codex_otel::GOAL_DURATION_SECONDS_METRIC; -use codex_otel::GOAL_RESUMED_METRIC; -use codex_otel::GOAL_TOKEN_COUNT_METRIC; -use codex_otel::GOAL_USAGE_LIMITED_METRIC; -use codex_prompts::budget_limit_prompt; -use codex_prompts::continuation_prompt; -use codex_prompts::objective_updated_prompt; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ModeKind; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ThreadGoal; -use codex_protocol::protocol::ThreadGoalStatus; -use codex_protocol::protocol::ThreadGoalUpdatedEvent; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::validate_thread_goal_objective; -use codex_rollout::state_db::reconcile_rollout; -use codex_thread_store::LocalThreadStore; -use futures::future::BoxFuture; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; -use tokio::sync::Mutex; -use tokio::sync::Semaphore; -use tokio::sync::SemaphorePermit; - -pub(crate) struct SetGoalRequest { - pub(crate) objective: Option, - pub(crate) status: Option, - pub(crate) token_budget: Option>, -} - -pub(crate) struct CreateGoalRequest { - pub(crate) objective: String, - pub(crate) token_budget: Option, -} - -#[derive(Clone, Copy)] -enum BudgetLimitSteering { - Allowed, - Suppressed, -} - -#[derive(Clone, Copy)] -enum TerminalMetricEmission { - Emit, - Suppress, -} - -/// Describes whether an external goal mutation created a new logical goal or -/// updated an existing one. -#[derive(Clone)] -pub enum ExternalGoalPreviousStatus { - NewGoal, - Existing(ExternalGoalPreviousGoal), -} - -#[derive(Clone)] -pub struct ExternalGoalPreviousGoal { - goal_id: String, - status: codex_state::ThreadGoalStatus, - objective: String, -} - -impl From<&codex_state::ThreadGoal> for ExternalGoalPreviousStatus { - fn from(goal: &codex_state::ThreadGoal) -> Self { - Self::Existing(ExternalGoalPreviousGoal::from(goal)) - } -} - -impl From<&codex_state::ThreadGoal> for ExternalGoalPreviousGoal { - fn from(goal: &codex_state::ThreadGoal) -> Self { - Self { - goal_id: goal.goal_id.clone(), - status: goal.status, - objective: goal.objective.clone(), - } - } -} - -/// Runtime effects for an externally persisted goal mutation. -#[derive(Clone)] -pub struct ExternalGoalSet { - pub goal: codex_state::ThreadGoal, - pub previous_status: ExternalGoalPreviousStatus, -} - -/// Runtime lifecycle events that can affect goal accounting, scheduling, or -/// model-visible steering. -/// -/// Callers report the session event they observed; this module owns the policy -/// for how that event changes goal runtime state. -pub(crate) enum GoalRuntimeEvent<'a> { - TurnStarted { - turn_context: &'a TurnContext, - token_usage: TokenUsage, - }, - ToolCompleted { - turn_context: &'a TurnContext, - tool_name: &'a str, - }, - ToolCompletedGoal { - turn_context: &'a TurnContext, - }, - TurnFinished { - turn_context: &'a TurnContext, - turn_completed: bool, - }, - MaybeContinueIfIdle, - TaskAborted { - turn_context: Option<&'a TurnContext>, - }, - UsageLimitReached { - turn_context: &'a TurnContext, - }, - ExternalMutationStarting, - ExternalSet { - external_set: ExternalGoalSet, - }, - ExternalClear, - ThreadResumed, -} - -pub(crate) struct GoalRuntimeState { - pub(crate) state_db: Mutex>, - pub(crate) budget_limit_reported_goal_id: Mutex>, - accounting_lock: Semaphore, - accounting: Mutex, - pub(crate) continuation_lock: Semaphore, -} - -struct GoalContinuationCandidate { - goal_id: String, - items: Vec, -} - -impl GoalRuntimeState { - pub(crate) fn new() -> Self { - Self { - state_db: Mutex::new(None), - budget_limit_reported_goal_id: Mutex::new(None), - accounting_lock: Semaphore::new(/*permits*/ 1), - accounting: Mutex::new(GoalAccountingSnapshot::new()), - continuation_lock: Semaphore::new(/*permits*/ 1), - } - } -} - -#[derive(Debug)] -struct GoalAccountingSnapshot { - turn: Option, - wall_clock: GoalWallClockAccountingSnapshot, -} - -#[derive(Debug)] -struct GoalTurnAccountingSnapshot { - turn_id: String, - last_accounted_token_usage: TokenUsage, - active_goal_id: Option, -} - -impl GoalRuntimeState { - async fn accounting_permit(&self) -> anyhow::Result> { - self.accounting_lock - .acquire() - .await - .context("goal accounting semaphore closed") - } -} - -impl GoalAccountingSnapshot { - fn new() -> Self { - Self { - turn: None, - wall_clock: GoalWallClockAccountingSnapshot::new(), - } - } -} - -impl GoalTurnAccountingSnapshot { - fn new(turn_id: impl Into, token_usage: TokenUsage) -> Self { - Self { - turn_id: turn_id.into(), - last_accounted_token_usage: token_usage, - active_goal_id: None, - } - } - - fn mark_active_goal(&mut self, goal_id: impl Into) { - self.active_goal_id = Some(goal_id.into()); - } - - fn active_this_turn(&self) -> bool { - self.active_goal_id.is_some() - } - - fn active_goal_id(&self) -> Option { - self.active_goal_id.clone() - } - - fn clear_active_goal(&mut self) { - self.active_goal_id = None; - } - - fn reset_baseline(&mut self, token_usage: TokenUsage) { - self.last_accounted_token_usage = token_usage; - } - - fn token_delta_since_last_accounting(&self, current: &TokenUsage) -> i64 { - let last = &self.last_accounted_token_usage; - let delta = TokenUsage { - input_tokens: current.input_tokens.saturating_sub(last.input_tokens), - cached_input_tokens: current - .cached_input_tokens - .saturating_sub(last.cached_input_tokens), - output_tokens: current.output_tokens.saturating_sub(last.output_tokens), - reasoning_output_tokens: current - .reasoning_output_tokens - .saturating_sub(last.reasoning_output_tokens), - total_tokens: current.total_tokens.saturating_sub(last.total_tokens), - }; - goal_token_delta_for_usage(&delta) - } - - fn mark_accounted(&mut self, current: TokenUsage) { - self.last_accounted_token_usage = current; - } -} - -#[derive(Debug)] -struct GoalWallClockAccountingSnapshot { - last_accounted_at: Instant, - active_goal_id: Option, -} - -impl GoalWallClockAccountingSnapshot { - fn new() -> Self { - Self { - last_accounted_at: Instant::now(), - active_goal_id: None, - } - } - - fn time_delta_since_last_accounting(&self) -> i64 { - let last = self.last_accounted_at; - i64::try_from(last.elapsed().as_secs()).unwrap_or(i64::MAX) - } - - fn mark_accounted(&mut self, accounted_seconds: i64) { - if accounted_seconds <= 0 { - return; - } - let advance = Duration::from_secs(u64::try_from(accounted_seconds).unwrap_or(u64::MAX)); - self.last_accounted_at = self - .last_accounted_at - .checked_add(advance) - .unwrap_or_else(Instant::now); - } - - fn reset_baseline(&mut self) { - self.last_accounted_at = Instant::now(); - } - - fn mark_active_goal(&mut self, goal_id: impl Into) { - let goal_id = goal_id.into(); - if self.active_goal_id.as_deref() != Some(goal_id.as_str()) { - self.reset_baseline(); - self.active_goal_id = Some(goal_id); - } - } - - fn clear_active_goal(&mut self) { - self.active_goal_id = None; - self.reset_baseline(); - } - - fn active_goal_id(&self) -> Option { - self.active_goal_id.clone() - } -} - -impl Session { - /// Applies runtime policy for a goal lifecycle event. - /// - /// Goal data methods validate and persist state; this dispatcher owns the - /// cross-cutting runtime behavior: plan mode ignores continuations, turn - /// starts capture the active goal and token baseline, tool completions - /// account usage and may inject budget steering, completion accounting - /// suppresses that steering, external mutations account best-effort before - /// changing state, thread resumes restore runtime state for already-active - /// goals, explicit maybe-continue events - /// start idle goal continuation turns, and continuation turns with no counted - /// autonomous activity suppress the next automatic continuation until - /// user/tool/external activity resets it. - pub(crate) fn goal_runtime_apply<'a>( - self: &'a Arc, - event: GoalRuntimeEvent<'a>, - ) -> BoxFuture<'a, anyhow::Result<()>> { - match event { - GoalRuntimeEvent::TurnStarted { - turn_context, - token_usage, - } => Box::pin(async move { - self.mark_thread_goal_turn_started(turn_context, token_usage) - .await; - Ok(()) - }), - GoalRuntimeEvent::ToolCompleted { - turn_context, - tool_name, - } => Box::pin(async move { - if tool_name != UPDATE_GOAL_TOOL_NAME { - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Allowed, - TerminalMetricEmission::Emit, - ) - .await?; - } - Ok(()) - }), - GoalRuntimeEvent::ToolCompletedGoal { turn_context } => Box::pin(async move { - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Suppress, - ) - .await?; - Ok(()) - }), - GoalRuntimeEvent::TurnFinished { - turn_context, - turn_completed, - } => Box::pin(async move { - self.finish_thread_goal_turn(turn_context, turn_completed) - .await; - Ok(()) - }), - GoalRuntimeEvent::MaybeContinueIfIdle => Box::pin(async move { - self.maybe_continue_goal_if_idle_runtime().await; - Ok(()) - }), - GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move { - self.handle_thread_goal_task_abort(turn_context).await; - Ok(()) - }), - GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { - self.usage_limit_active_thread_goal_for_turn(turn_context) - .await?; - Ok(()) - }), - GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move { - if let Err(err) = self.account_thread_goal_before_external_mutation().await { - tracing::warn!( - "failed to account thread goal progress before external mutation: {err}" - ); - } - Ok(()) - }), - GoalRuntimeEvent::ExternalSet { external_set } => Box::pin(async move { - self.apply_external_thread_goal_status(external_set).await; - Ok(()) - }), - GoalRuntimeEvent::ExternalClear => Box::pin(async move { - self.clear_stopped_thread_goal_runtime_state().await; - Ok(()) - }), - GoalRuntimeEvent::ThreadResumed => Box::pin(async move { - self.restore_thread_goal_runtime_after_resume().await?; - Ok(()) - }), - } - } - - pub(crate) async fn get_thread_goal(&self) -> anyhow::Result> { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let state_db = self.require_state_db_for_thread_goals().await?; - state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - .map(|goal| goal.map(protocol_goal_from_state)) - } - - pub(crate) async fn set_thread_goal( - &self, - turn_context: &TurnContext, - request: SetGoalRequest, - ) -> anyhow::Result { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let SetGoalRequest { - objective, - status, - token_budget, - } = request; - validate_goal_budget(token_budget.flatten())?; - let state_db = self.require_state_db_for_thread_goals().await?; - let objective = objective.map(|objective| objective.trim().to_string()); - if let Some(objective) = objective.as_deref() - && let Err(err) = validate_thread_goal_objective(objective) - { - anyhow::bail!("{err}"); - } - - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let mut replacing_goal = false; - let previous_status; - let goal = if let Some(objective) = objective.as_deref() { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - previous_status = existing_goal.as_ref().map(|goal| goal.status); - if let Some(existing_goal) = existing_goal.as_ref() { - state_db - .thread_goals() - .update_thread_goal( - self.thread_id, - codex_state::GoalUpdate { - objective: Some(objective.to_string()), - status: status.map(state_goal_status_from_protocol), - token_budget, - expected_goal_id: Some(existing_goal.goal_id.clone()), - }, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {}: no goal exists", - self.thread_id - ) - })? - } else { - replacing_goal = true; - state_db - .thread_goals() - .replace_thread_goal( - self.thread_id, - objective, - status - .map(state_goal_status_from_protocol) - .unwrap_or(codex_state::ThreadGoalStatus::Active), - token_budget.flatten(), - ) - .await? - } - } else { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - previous_status = existing_goal.as_ref().map(|goal| goal.status); - let expected_goal_id = existing_goal.map(|goal| goal.goal_id); - let status = status.map(state_goal_status_from_protocol); - state_db - .thread_goals() - .update_thread_goal( - self.thread_id, - codex_state::GoalUpdate { - objective: None, - status, - token_budget, - expected_goal_id, - }, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {}: no goal exists", - self.thread_id - ) - })? - }; - - if objective.is_some() { - set_thread_preview_from_goal_objective( - &state_db, - self.thread_id, - goal.objective.as_str(), - ) - .await; - } - let goal_status = goal.status; - let goal_id = goal.goal_id.clone(); - let previous_status_for_goal = if replacing_goal { - None - } else { - previous_status - }; - if replacing_goal { - self.emit_goal_created_metric(); - } - self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status); - self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - let newly_active_goal = goal_status == codex_state::ThreadGoalStatus::Active - && (replacing_goal - || previous_status - .is_some_and(|status| status != codex_state::ThreadGoalStatus::Active)); - if newly_active_goal { - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal_id, - Some(turn_context.sub_id.clone()), - current_token_usage, - ) - .await; - } else if goal_status != codex_state::ThreadGoalStatus::Active { - self.clear_active_goal_accounting(turn_context).await; - } - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - Ok(goal) - } - - pub(crate) async fn create_thread_goal( - &self, - turn_context: &TurnContext, - request: CreateGoalRequest, - ) -> anyhow::Result { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let CreateGoalRequest { - objective, - token_budget, - } = request; - validate_goal_budget(token_budget)?; - let objective = objective.trim(); - validate_thread_goal_objective(objective).map_err(anyhow::Error::msg)?; - - let state_db = self.require_state_db_for_thread_goals().await?; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let goal = state_db - .thread_goals() - .insert_thread_goal( - self.thread_id, - objective, - codex_state::ThreadGoalStatus::Active, - token_budget, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot create a new goal because thread {} already has a goal", - self.thread_id - ) - })?; - - set_thread_preview_from_goal_objective(&state_db, self.thread_id, goal.objective.as_str()) - .await; - let goal_id = goal.goal_id.clone(); - self.emit_goal_created_metric(); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal_id, - Some(turn_context.sub_id.clone()), - current_token_usage, - ) - .await; - - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - Ok(goal) - } - - async fn apply_external_thread_goal_status(self: &Arc, external_set: ExternalGoalSet) { - let ExternalGoalSet { - goal, - previous_status, - } = external_set; - let previous_goal = match previous_status { - ExternalGoalPreviousStatus::NewGoal => None, - ExternalGoalPreviousStatus::Existing(goal) => Some(goal), - }; - let replaced_existing_goal = previous_goal - .as_ref() - .is_some_and(|previous_goal| previous_goal.goal_id != goal.goal_id); - if previous_goal.is_none() || replaced_existing_goal { - self.emit_goal_created_metric(); - } - let objective_changed = previous_goal - .as_ref() - .is_some_and(|previous_goal| previous_goal.objective != goal.objective); - let previous_status = previous_goal - .as_ref() - .and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status)); - self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status); - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone())); - let goal_id = goal.goal_id; - let status = goal.status; - match status { - codex_state::ThreadGoalStatus::Active => { - let turn_id = self - .active_turn_context() - .await - .map(|turn_context| turn_context.sub_id.clone()); - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting(goal_id, turn_id, current_token_usage) - .await; - if let Some(goal) = goal_for_steering { - let item = goal_context_input_item(objective_updated_prompt(&goal)); - if self.inject_if_running(vec![item]).await.is_err() { - tracing::debug!( - "skipping objective-updated goal steering because no turn is active" - ); - } - } - self.maybe_continue_goal_if_idle_runtime().await; - } - codex_state::ThreadGoalStatus::BudgetLimited => { - if self.active_turn_context().await.is_none() { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::Complete => { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - } - - async fn clear_stopped_thread_goal_runtime_state(&self) { - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() { - turn.clear_active_goal(); - } - accounting.wall_clock.clear_active_goal(); - } - - async fn clear_active_goal_accounting(&self, turn_context: &TurnContext) { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() - && turn.turn_id == turn_context.sub_id - { - turn.clear_active_goal(); - } - accounting.wall_clock.clear_active_goal(); - } - - async fn mark_active_goal_accounting( - &self, - goal_id: String, - turn_id: Option, - token_usage: TokenUsage, - ) { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn_id) = turn_id { - match accounting.turn.as_mut() { - Some(turn) if turn.turn_id == turn_id => { - turn.reset_baseline(token_usage); - turn.mark_active_goal(goal_id.clone()); - } - _ => { - let mut turn = GoalTurnAccountingSnapshot::new(turn_id, token_usage); - turn.mark_active_goal(goal_id.clone()); - accounting.turn = Some(turn); - } - } - } - accounting.wall_clock.mark_active_goal(goal_id); - } - - fn emit_goal_created_metric(&self) { - self.services - .session_telemetry - .counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]); - } - - fn emit_goal_resumed_metric(&self) { - self.services - .session_telemetry - .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); - } - - fn emit_goal_resumed_metric_if_status_changed( - &self, - previous_status: Option, - goal_status: codex_state::ThreadGoalStatus, - ) { - if goal_status == codex_state::ThreadGoalStatus::Active - && matches!( - previous_status, - Some( - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - ) - ) - { - self.emit_goal_resumed_metric(); - } - } - - fn emit_goal_terminal_metrics_if_status_changed( - &self, - previous_status: Option, - goal: &codex_state::ThreadGoal, - ) { - if previous_status == Some(goal.status) { - return; - } - - let counter = match goal.status { - codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC, - codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC, - codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, - codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, - codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { - return; - } - }; - let status_tag = [("status", goal.status.as_str())]; - self.services - .session_telemetry - .counter(counter, /*inc*/ 1, &[]); - self.services.session_telemetry.histogram( - GOAL_TOKEN_COUNT_METRIC, - goal.tokens_used, - &status_tag, - ); - self.services.session_telemetry.histogram( - GOAL_DURATION_SECONDS_METRIC, - goal.time_used_seconds, - &status_tag, - ); - } - - async fn current_goal_status_for_metrics( - &self, - state_db: &StateDbHandle, - expected_goal_id: Option<&str>, - ) -> anyhow::Result> { - let goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - Ok(goal.and_then(|goal| { - expected_goal_id - .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id) - .then_some(goal.status) - })) - } - - async fn active_turn_context(&self) -> Option> { - let active = self.active_turn.lock().await; - active - .as_ref() - .and_then(|active_turn| active_turn.task.as_ref()) - .map(|task| Arc::clone(&task.turn_context)) - } - - async fn mark_thread_goal_turn_started( - &self, - turn_context: &TurnContext, - token_usage: TokenUsage, - ) { - self.goal_runtime.accounting.lock().await.turn = Some(GoalTurnAccountingSnapshot::new( - turn_context.sub_id.clone(), - token_usage, - )); - - if !self.enabled(Feature::Goals) { - return; - } - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - self.clear_active_goal_accounting(turn_context).await; - return; - } - let state_db = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => state_db, - Ok(None) => return, - Err(err) => { - tracing::warn!("failed to open state db at turn start: {err}"); - return; - } - }; - match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) - if matches!( - goal.status, - codex_state::ThreadGoalStatus::Active - | codex_state::ThreadGoalStatus::BudgetLimited - ) => - { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() - && turn.turn_id == turn_context.sub_id - { - turn.mark_active_goal(goal.goal_id.clone()); - } - accounting.wall_clock.mark_active_goal(goal.goal_id); - } - Ok(Some(_)) | Ok(None) => { - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .clear_active_goal(); - } - Err(err) => { - tracing::warn!("failed to read thread goal at turn start: {err}"); - } - } - } - - async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc>) { - let mut active_turn_guard = self.active_turn.lock().await; - if let Some(active_turn) = active_turn_guard.as_ref() - && active_turn.task.is_none() - && Arc::ptr_eq(&active_turn.turn_state, turn_state) - { - *active_turn_guard = None; - } - } - - async fn finish_thread_goal_turn( - self: &Arc, - turn_context: &TurnContext, - turn_completed: bool, - ) { - if turn_completed - && let Err(err) = self - .account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await - { - tracing::warn!("failed to account thread goal progress at turn end: {err}"); - } - - if turn_completed { - let mut accounting = self.goal_runtime.accounting.lock().await; - if accounting - .turn - .as_ref() - .is_some_and(|turn| turn.turn_id == turn_context.sub_id) - { - accounting.turn = None; - } - } - } - - async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { - if let Some(turn_context) = turn_context { - if let Err(err) = self - .account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await - { - tracing::warn!("failed to account thread goal progress after abort: {err}"); - } - let mut accounting = self.goal_runtime.accounting.lock().await; - if accounting - .turn - .as_ref() - .is_some_and(|turn| turn.turn_id == turn_context.sub_id) - { - accounting.turn = None; - } - } - } - - async fn account_thread_goal_progress( - &self, - turn_context: &TurnContext, - budget_limit_steering: BudgetLimitSteering, - terminal_metric_emission: TerminalMetricEmission, - ) -> anyhow::Result<()> { - if !self.enabled(Feature::Goals) { - return Ok(()); - } - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - return Ok(()); - } - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - let _accounting_permit = self.goal_runtime.accounting_permit().await?; - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - let (token_delta, expected_goal_id, time_delta_seconds) = { - let accounting = self.goal_runtime.accounting.lock().await; - let Some(turn) = accounting - .turn - .as_ref() - .filter(|turn| turn.turn_id == turn_context.sub_id) - else { - return Ok(()); - }; - if !turn.active_this_turn() { - return Ok(()); - } - ( - turn.token_delta_since_last_accounting(¤t_token_usage), - turn.active_goal_id(), - accounting.wall_clock.time_delta_since_last_accounting(), - ) - }; - if time_delta_seconds == 0 && token_delta <= 0 { - return Ok(()); - } - let previous_status = self - .current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref()) - .await?; - let outcome = state_db - .thread_goals() - .account_thread_goal_usage( - self.thread_id, - time_delta_seconds, - token_delta, - codex_state::GoalAccountingMode::ActiveOnly, - expected_goal_id.as_deref(), - ) - .await?; - let budget_limit_was_already_reported = { - let reported_goal_id = self.goal_runtime.budget_limit_reported_goal_id.lock().await; - expected_goal_id - .as_deref() - .is_some_and(|goal_id| reported_goal_id.as_deref() == Some(goal_id)) - }; - let goal = match outcome { - codex_state::GoalAccountingOutcome::Updated(goal) => { - let clear_active_goal = match goal.status { - codex_state::ThreadGoalStatus::Active => false, - codex_state::ThreadGoalStatus::BudgetLimited => { - matches!(budget_limit_steering, BudgetLimitSteering::Suppressed) - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::Complete => true, - }; - { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting - .turn - .as_mut() - .filter(|turn| turn.turn_id == turn_context.sub_id) - { - turn.mark_accounted(current_token_usage); - if clear_active_goal { - turn.clear_active_goal(); - } - } - accounting.wall_clock.mark_accounted(time_delta_seconds); - if clear_active_goal { - accounting.wall_clock.clear_active_goal(); - } - } - if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - } - goal - } - codex_state::GoalAccountingOutcome::Unchanged(_) => return Ok(()), - }; - let should_steer_budget_limit = - matches!(budget_limit_steering, BudgetLimitSteering::Allowed) - && goal.status == codex_state::ThreadGoalStatus::BudgetLimited - && !budget_limit_was_already_reported; - let goal_status = goal.status; - let goal_id = goal.goal_id.clone(); - if goal_status != codex_state::ThreadGoalStatus::BudgetLimited { - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - } - let goal = protocol_goal_from_state(goal); - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - if should_steer_budget_limit { - let item = budget_limit_steering_item(&goal); - if self.inject_if_running(vec![item]).await.is_err() { - tracing::debug!("skipping budget-limit goal steering because no turn is active"); - } - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = Some(goal_id); - } - Ok(()) - } - - async fn account_thread_goal_before_external_mutation(&self) -> anyhow::Result<()> { - if let Some(turn_context) = self.active_turn_context().await { - return self - .account_thread_goal_progress( - turn_context.as_ref(), - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await; - } - - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Suppress, - ) - .await?; - Ok(()) - } - - async fn account_thread_goal_wall_clock_usage( - &self, - state_db: &StateDbHandle, - mode: codex_state::GoalAccountingMode, - terminal_metric_emission: TerminalMetricEmission, - ) -> anyhow::Result> { - let _accounting_permit = self.goal_runtime.accounting_permit().await?; - let (time_delta_seconds, expected_goal_id) = { - let accounting = self.goal_runtime.accounting.lock().await; - ( - accounting.wall_clock.time_delta_since_last_accounting(), - accounting.wall_clock.active_goal_id(), - ) - }; - if time_delta_seconds == 0 { - return Ok(None); - } - let previous_status = self - .current_goal_status_for_metrics(state_db, expected_goal_id.as_deref()) - .await?; - - match state_db - .thread_goals() - .account_thread_goal_usage( - self.thread_id, - time_delta_seconds, - /*token_delta*/ 0, - mode, - expected_goal_id.as_deref(), - ) - .await? - { - codex_state::GoalAccountingOutcome::Updated(goal) => { - if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - } - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .mark_accounted(time_delta_seconds); - let goal = protocol_goal_from_state(goal); - Ok(Some(goal)) - } - codex_state::GoalAccountingOutcome::Unchanged(goal) => { - { - let mut accounting = self.goal_runtime.accounting.lock().await; - accounting.wall_clock.reset_baseline(); - accounting.wall_clock.clear_active_goal(); - } - if let Some(goal) = goal { - let goal = protocol_goal_from_state(goal); - return Ok(Some(goal)); - } - Ok(None) - } - } - } - - async fn usage_limit_active_thread_goal_for_turn( - &self, - turn_context: &TurnContext, - ) -> anyhow::Result<()> { - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - return Ok(()); - } - - if !self.enabled(Feature::Goals) { - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await?; - let previous_status = self - .current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None) - .await?; - let Some(goal) = state_db - .thread_goals() - .usage_limit_active_thread_goal(self.thread_id) - .await? - else { - return Ok(()); - }; - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - self.clear_active_goal_accounting(turn_context).await; - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal, - }), - ) - .await; - Ok(()) - } - - async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> { - if !self.enabled(Feature::Goals) { - return Ok(()); - } - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - tracing::debug!( - "skipping goal runtime restore while current collaboration mode ignores goals" - ); - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - let Some(goal) = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await? - else { - self.clear_stopped_thread_goal_runtime_state().await; - return Ok(()); - }; - match goal.status { - codex_state::ThreadGoalStatus::Active => { - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .mark_active_goal(goal.goal_id); - self.emit_goal_resumed_metric(); - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::BudgetLimited - | codex_state::ThreadGoalStatus::Complete => { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - Ok(()) - } - - async fn maybe_continue_goal_if_idle_runtime(self: &Arc) { - self.maybe_start_turn_for_pending_work().await; - self.maybe_start_goal_continuation_turn().await; - } - - async fn maybe_start_goal_continuation_turn(self: &Arc) { - let Ok(_continuation_guard) = self.goal_runtime.continuation_lock.acquire().await else { - tracing::warn!("goal continuation semaphore closed"); - return; - }; - let Some(candidate) = self.goal_continuation_candidate_if_active().await else { - return; - }; - - let turn_state = { - let mut active_turn = self.active_turn.lock().await; - if active_turn.is_some() { - return; - } - let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); - Arc::clone(&active_turn.turn_state) - }; - let goal_is_current = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) - if goal.goal_id == candidate.goal_id - && goal.status == codex_state::ThreadGoalStatus::Active => - { - true - } - Ok(Some(_)) | Ok(None) => { - tracing::debug!( - "skipping active goal continuation because the goal changed before launch" - ); - false - } - Err(err) => { - tracing::warn!("failed to re-read thread goal before continuation: {err}"); - false - } - }, - Ok(None) => { - tracing::debug!("skipping active goal continuation for ephemeral thread"); - false - } - Err(err) => { - tracing::warn!("failed to open state db before goal continuation: {err}"); - false - } - }; - if !goal_is_current { - self.clear_reserved_goal_continuation_turn(&turn_state) - .await; - return; - } - self.input_queue - .extend_pending_input_for_turn_state( - turn_state.as_ref(), - candidate - .items - .into_iter() - .map(TurnInput::ResponseItem) - .collect(), - ) - .await; - - let turn_context = self - .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) - .await; - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) - .await; - let still_reserved = { - let active_turn = self.active_turn.lock().await; - active_turn.as_ref().is_some_and(|active_turn| { - active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state) - }) - }; - if !still_reserved { - self.clear_reserved_goal_continuation_turn(&turn_state) - .await; - return; - } - self.start_task(turn_context, Vec::new(), RegularTask::new()) - .await; - } - - async fn goal_continuation_candidate_if_active( - self: &Arc, - ) -> Option { - if !self.enabled(Feature::Goals) { - return None; - } - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - tracing::debug!("skipping active goal continuation while plan mode is active"); - return None; - } - if self.active_turn.lock().await.is_some() { - tracing::debug!("skipping active goal continuation because a turn is already active"); - return None; - } - if self.input_queue.has_trigger_turn_mailbox_items().await { - tracing::debug!( - "skipping active goal continuation because trigger-turn mailbox input is pending" - ); - return None; - } - let state_db = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => state_db, - Ok(None) => { - tracing::debug!("skipping active goal continuation for ephemeral thread"); - return None; - } - Err(err) => { - tracing::warn!("failed to open state db for goal continuation: {err}"); - return None; - } - }; - let goal = match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) => goal, - Ok(None) => { - tracing::debug!("skipping active goal continuation because no goal is set"); - return None; - } - Err(err) => { - tracing::warn!("failed to read thread goal for continuation: {err}"); - return None; - } - }; - if goal.status != codex_state::ThreadGoalStatus::Active { - tracing::debug!(status = ?goal.status, "skipping inactive thread goal"); - return None; - } - if self.active_turn.lock().await.is_some() - || self.input_queue.has_trigger_turn_mailbox_items().await - { - tracing::debug!("skipping active goal continuation because pending work appeared"); - return None; - } - let goal_id = goal.goal_id.clone(); - let goal = protocol_goal_from_state(goal); - Some(GoalContinuationCandidate { - goal_id, - items: vec![goal_context_input_item(continuation_prompt(&goal))], - }) - } -} - -impl Session { - async fn state_db_for_thread_goals(&self) -> anyhow::Result> { - let config = self.get_config().await; - if config.ephemeral { - return Ok(None); - } - - self.try_ensure_rollout_materialized() - .await - .context("failed to materialize rollout before opening state db for thread goals")?; - - let state_db = if let Some(state_db) = self.state_db() { - state_db - } else if let Some(state_db) = self.goal_runtime.state_db.lock().await.clone() { - state_db - } else if let Some(local_store) = self - .services - .thread_store - .as_any() - .downcast_ref::() - { - local_store.state_db().await.ok_or_else(|| { - anyhow::anyhow!( - "thread goals require a local persisted thread with a state database" - ) - })? - } else { - anyhow::bail!("thread goals require a local persisted thread with a state database"); - }; - - let thread_metadata_present = state_db - .get_thread(self.thread_id) - .await - .context("failed to read thread metadata before reconciling thread goals")? - .is_some(); - if !thread_metadata_present { - let rollout_path = self - .current_rollout_path() - .await - .context("failed to locate rollout before reconciling thread goals")? - .ok_or_else(|| { - anyhow::anyhow!("thread goals require materialized thread metadata") - })?; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - let thread_metadata_present = state_db - .get_thread(self.thread_id) - .await - .context("failed to read thread metadata after reconciling thread goals")? - .is_some(); - if !thread_metadata_present { - anyhow::bail!("thread metadata is unavailable after reconciling thread goals"); - } - } - - *self.goal_runtime.state_db.lock().await = Some(state_db.clone()); - Ok(Some(state_db)) - } - - async fn require_state_db_for_thread_goals(&self) -> anyhow::Result { - self.state_db_for_thread_goals().await?.ok_or_else(|| { - anyhow::anyhow!("thread goals require a persisted thread; this thread is ephemeral") - }) - } -} - -async fn set_thread_preview_from_goal_objective( - state_db: &StateDbHandle, - thread_id: ThreadId, - objective: &str, -) { - if let Err(err) = state_db - .set_thread_preview_if_empty(thread_id, objective) - .await - { - tracing::warn!( - "failed to set empty thread preview from goal objective for {thread_id}: {err}" - ); - } -} - -fn should_ignore_goal_for_mode(mode: ModeKind) -> bool { - mode == ModeKind::Plan -} - -fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { - goal_context_input_item(budget_limit_prompt(goal)) -} - -fn goal_context_input_item(prompt: String) -> ResponseItem { - ContextualUserFragment::into(InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - prompt, - )) -} - -pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { - ThreadGoal { - thread_id: goal.thread_id, - objective: goal.objective, - status: protocol_goal_status_from_state(goal.status), - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at.timestamp(), - updated_at: goal.updated_at.timestamp(), - } -} - -pub(crate) fn protocol_goal_status_from_state( - status: codex_state::ThreadGoalStatus, -) -> ThreadGoalStatus { - match status { - codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, - codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, - codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, - codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, - codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, - codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, - } -} - -pub(crate) fn state_goal_status_from_protocol( - status: ThreadGoalStatus, -) -> codex_state::ThreadGoalStatus { - match status { - ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, - ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, - ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, - ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, - ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, - ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, - } -} - -pub(crate) fn validate_goal_budget(value: Option) -> anyhow::Result<()> { - if let Some(value) = value - && value <= 0 - { - anyhow::bail!("goal budgets must be positive when provided"); - } - Ok(()) -} - -pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { - usage - .non_cached_input() - .saturating_add(usage.output_tokens.max(0)) -} - -#[cfg(test)] -mod tests { - use super::goal_context_input_item; - use super::goal_token_delta_for_usage; - use super::should_ignore_goal_for_mode; - use codex_protocol::config_types::ModeKind; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseItem; - use codex_protocol::protocol::TokenUsage; - use std::time::Duration; - use std::time::Instant; - - #[test] - fn goal_continuation_is_ignored_only_in_plan_mode() { - assert!(should_ignore_goal_for_mode(ModeKind::Plan)); - assert!(!should_ignore_goal_for_mode(ModeKind::Default)); - assert!(!should_ignore_goal_for_mode(ModeKind::PairProgramming)); - assert!(!should_ignore_goal_for_mode(ModeKind::Execute)); - } - - #[test] - fn goal_token_delta_excludes_cached_input_and_does_not_double_count_reasoning() { - let usage = TokenUsage { - input_tokens: 900, - cached_input_tokens: 400, - output_tokens: 80, - reasoning_output_tokens: 20, - total_tokens: 1_000, - }; - - assert_eq!(580, goal_token_delta_for_usage(&usage)); - } - - #[test] - fn wall_clock_accounting_advances_by_persisted_seconds() { - let mut snapshot = super::GoalWallClockAccountingSnapshot::new(); - let original = Instant::now() - Duration::from_millis(1500); - snapshot.last_accounted_at = original; - - snapshot.mark_accounted(/*accounted_seconds*/ 1); - assert_eq!( - original + Duration::from_secs(1), - snapshot.last_accounted_at - ); - - let token_only_original = snapshot.last_accounted_at; - snapshot.mark_accounted(/*accounted_seconds*/ 0); - assert_eq!(token_only_original, snapshot.last_accounted_at); - } - - #[test] - fn goal_context_input_item_is_hidden_user_context() { - let item = goal_context_input_item("Continue working.".to_string()); - - assert_eq!( - item, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\nContinue working.\n".to_string(), - }], - phase: None, - } - ); - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4dc19de7e6..400c803680 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,9 +39,6 @@ pub mod exec_env; mod exec_policy; #[cfg(test)] mod git_info_tests; -mod goals; -pub use goals::ExternalGoalPreviousStatus; -pub use goals::ExternalGoalSet; mod guardian; mod hook_runtime; mod installation_id; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index ff4c77aab9..a3b23c6a78 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -26,7 +26,6 @@ pub(super) async fn spawn_review_thread( let _ = review_features.disable(Feature::WebSearchCached); let _ = review_features.disable(Feature::Goals); let review_web_search_mode = WebSearchMode::Disabled; - let goal_tools_supported = !config.ephemeral && parent_turn_context.goal_tools_enabled(); let available_models = sess .services .models_manager @@ -126,7 +125,6 @@ pub(super) async fn spawn_review_thread( environments: parent_turn_context.environments.clone(), available_models, unified_exec_shell_mode, - goal_tools_supported, features: review_features, ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), current_date: parent_turn_context.current_date.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e7ec3b98db..122ff6aeb9 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -2,7 +2,6 @@ use super::input_queue::InputQueue; use super::*; use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; -use crate::goals::GoalRuntimeState; use crate::skills::SkillError; use crate::state::ActiveTurn; use codex_protocol::SessionId; @@ -37,7 +36,6 @@ pub(crate) struct Session { pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, pub(crate) input_queue: InputQueue, - pub(crate) goal_runtime: GoalRuntimeState, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, pub(super) next_internal_sub_id: AtomicU64, @@ -1059,7 +1057,6 @@ impl Session { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: InputQueue::new(), - goal_runtime: GoalRuntimeState::new(), guardian_review_session: GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index cf9c7c48d3..366cf6f22a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -57,11 +57,6 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; -use crate::goals::CreateGoalRequest; -use crate::goals::ExternalGoalPreviousStatus; -use crate::goals::ExternalGoalSet; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; use crate::rollout::recorder::RolloutRecorder; use crate::state::ActiveTurn; use crate::state::TaskKind; @@ -72,11 +67,9 @@ use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; @@ -128,7 +121,6 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; -use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TokenCountEvent; @@ -139,28 +131,21 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; -use codex_protocol::request_user_input::RequestUserInputAnswer; -use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; -use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_completed_with_tokens; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; -use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_match; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; use opentelemetry_sdk::metrics::InMemoryMetricExporter; @@ -4898,7 +4883,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, - /*goal_tools_supported*/ true, ); let session = Session { @@ -4915,7 +4899,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -6782,18 +6765,7 @@ where let (tx_event, rx_event) = async_channel::unbounded(); let mut config = build_test_config(codex_home).await; configure_config(&mut config); - let state_db = if config.features.enabled(Feature::Goals) { - Some( - codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ) - .await - .expect("goal tests should initialize sqlite state db"), - ) - } else { - None - }; + let state_db = None; let config = Arc::new(config); let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); @@ -6988,7 +6960,6 @@ where session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, - /*goal_tools_supported*/ true, )); let session = Arc::new(Session { @@ -7005,7 +6976,6 @@ where conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -7029,52 +6999,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( .await } -async fn make_goal_session_and_context_with_rx() -> ( - Arc, - Arc, - async_channel::Receiver, - tempfile::TempDir, -) { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - codex_home.path(), - |config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }, - ) - .await; - upsert_goal_test_thread(session.as_ref()).await; - (session, turn_context, rx, codex_home) -} - -async fn upsert_goal_test_thread(session: &Session) { - let config = session.get_config().await; - let state_db = session - .state_db() - .expect("goal test session should have a state db"); - let mut builder = codex_state::ThreadMetadataBuilder::new( - session.thread_id, - config - .codex_home - .join("goal-test-rollout.jsonl") - .to_path_buf(), - chrono::Utc::now(), - SessionSource::Cli, - ); - builder.cwd = config.cwd.to_path_buf(); - builder.model_provider = Some(config.model_provider_id.clone()); - let metadata = builder.build(config.model_provider_id.as_str()); - state_db - .upsert_thread(&metadata) - .await - .expect("goal test thread should be upserted"); -} - // Like make_session_and_context, but returns Arc and the event receiver // so tests can assert on emitted events. pub(crate) async fn make_session_and_context_with_rx() -> ( @@ -9131,282 +9055,6 @@ async fn abort_empty_active_turn_preserves_pending_input() { ); } -#[tokio::test] -async fn interrupt_accounts_active_goal_without_pausing() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - let goal = sess - .get_thread_goal() - .await? - .expect("goal should remain persisted after interrupt"); - assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Active, - goal.status - ); - assert_eq!(70, goal.tokens_used); - - assert!(sess.active_turn.lock().await.is_none()); - - Ok(()) -} - -#[tokio::test] -async fn shutdown_without_active_turn_keeps_active_goal_active() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - - assert!(sess.active_turn.lock().await.is_none()); - assert!(handlers::shutdown(&sess, "shutdown".to_string()).await); - - let goal = sess - .get_thread_goal() - .await? - .expect("goal should remain persisted after shutdown"); - assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Active, - goal.status - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a benchmark note"}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Draft ready."), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "I am still working on the benchmark note."), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_assistant_message("msg-3", "Goal complete."), - ev_completed("resp-5"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a benchmark note".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - let mut completed_turns = 0; - tokio::time::timeout(std::time::Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - completed_turns += 1; - if completed_turns == 3 { - return anyhow::Ok(()); - } - } - } - }) - .await??; - - let goal_context_text = responses - .requests() - .into_iter() - .flat_map(|request| request.message_input_texts("user")) - .find(|text| text.contains("")) - .expect("goal context message should be present"); - assert!(goal_context_text.contains("Continue working toward the active thread goal.")); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - config - .features - .enable(Feature::DefaultModeRequestUserInput) - .expect("default-mode request_user_input should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a benchmark note"}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Draft ready."), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_function_call( - "call-ask-user", - "request_user_input", - r#"{"questions":[{"header":"Choice","id":"next_step","question":"Pick one","options":[{"label":"Outline","description":"Start with an outline."},{"label":"Draft","description":"Write a full draft."}]}]}"#, - ), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "Goal complete."), - ev_completed("resp-5"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a benchmark note".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - let request_user_input_event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::RequestUserInput(event) => Some(event.clone()), - _ => None, - }) - .await; - assert_eq!(3, responses.requests().len()); - assert!( - timeout(Duration::from_millis(200), test.codex.next_event()) - .await - .is_err(), - "waiting for request_user_input should keep the turn open without emitting more events" - ); - assert_eq!( - 3, - responses.requests().len(), - "waiting for request_user_input should not start another continuation request" - ); - - test.codex - .submit(Op::UserInputAnswer { - id: request_user_input_event.turn_id, - response: RequestUserInputResponse { - answers: std::collections::HashMap::from([( - "next_step".to_string(), - RequestUserInputAnswer { - answers: vec!["Outline".to_string()], - }, - )]), - }, - }) - .await?; - - let mut completed_turns = 0; - timeout(Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - completed_turns += 1; - if completed_turns == 1 { - return anyhow::Ok(()); - } - } - } - }) - .await??; - - assert_eq!(5, responses.requests().len()); - - Ok(()) -} - async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { let mut state = sess.state.lock().await; state.set_token_info(Some(TokenUsageInfo { @@ -9416,543 +9064,6 @@ async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { })); } -fn post_goal_token_usage() -> TokenUsage { - TokenUsage { - input_tokens: 50, - cached_input_tokens: 10, - output_tokens: 30, - reasoning_output_tokens: 5, - total_tokens: 75, - } -} - -async fn goal_test_state_db(sess: &Session) -> anyhow::Result { - if let Some(state_db) = sess.state_db() { - return Ok(state_db); - } - let config = sess.get_config().await; - codex_state::StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) - .await -} - -#[tokio::test] -async fn create_thread_goal_fills_empty_thread_preview() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let state_db = goal_test_state_db(sess.as_ref()).await?; - - let page = state_db - .list_threads( - /*page_size*/ 10, - codex_state::ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: codex_state::SortKey::UpdatedAt, - sort_direction: codex_state::SortDirection::Desc, - search_term: None, - }, - ) - .await?; - assert!(page.items.is_empty()); - - sess.create_thread_goal( - tc.as_ref(), - CreateGoalRequest { - objective: "Keep improving the benchmark".to_string(), - token_budget: None, - }, - ) - .await?; - - let page = state_db - .list_threads( - /*page_size*/ 10, - codex_state::ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: codex_state::SortKey::UpdatedAt, - sort_direction: codex_state::SortDirection::Desc, - search_term: None, - }, - ) - .await?; - let ids = page - .items - .iter() - .map(|thread| thread.id) - .collect::>(); - assert_eq!(vec![sess.thread_id], ids); - assert_eq!( - Some("Keep improving the benchmark"), - page.items[0].preview.as_deref() - ); - - Ok(()) -} - -#[tokio::test] -async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyhow::Result<()> { - let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: Some(Some(10)), - }, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: tc.as_ref(), - token_usage: TokenUsage::default(), - }) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 20, - cached_input_tokens: 0, - output_tokens: 5, - reasoning_output_tokens: 0, - total_tokens: 25, - }, - ) - .await; - - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: tc.as_ref(), - tool_name: "shell_command", - }) - .await?; - - let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - let [TurnInput::ResponseItem(ResponseItem::Message { role, content, .. })] = - pending_input.as_slice() - else { - panic!("expected one budget-limit steering message, got {pending_input:#?}"); - }; - assert_eq!("user", role); - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected one text span in budget-limit steering message, got {content:#?}"); - }; - assert!(text.starts_with("")); - assert!(text.trim_end().ends_with("")); - assert!(text.contains("budget_limited")); - assert!(text.to_lowercase().contains("wrap up this turn soon")); - assert!(sess.active_turn.lock().await.is_some()); - while let Ok(event) = rx.try_recv() { - assert!( - !matches!(event.msg, EventMsg::TurnAborted(_)), - "budget limit should steer the active turn instead of aborting it" - ); - } - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after accounting"); - assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); - assert_eq!(25, goal.tokens_used); - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 30, - cached_input_tokens: 0, - output_tokens: 10, - reasoning_output_tokens: 0, - total_tokens: 40, - }, - ) - .await; - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { - turn_context: tc.as_ref(), - }) - .await?; - - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after follow-up accounting"); - assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); - assert_eq!(40, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()> -{ - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: Some(Some(50)), - }, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: tc.as_ref(), - token_usage: TokenUsage::default(), - }) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: tc.as_ref(), - }) - .await?; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after usage limiting"); - assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); - assert_eq!(70, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await?; - assert!(sess.active_turn.lock().await.is_none()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) - .await?; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(70, goal.tokens_used); - - let previous_goal = goal.clone(); - let goal_id = goal.goal_id.clone(); - let updated_goal = state_db - .thread_goals() - .update_thread_goal( - sess.thread_id, - codex_state::GoalUpdate { - objective: None, - status: Some(codex_state::ThreadGoalStatus::Complete), - token_budget: None, - expected_goal_id: Some(goal_id), - }, - ) - .await? - .expect("goal status update should succeed"); - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal: updated_goal, - previous_status: ExternalGoalPreviousStatus::from(&previous_goal), - }, - }) - .await?; - - assert!(sess.active_turn.lock().await.is_some()); - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(codex_state::ThreadGoalStatus::Complete, goal.status); - assert_eq!(70, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let old_goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Keep improving the benchmark", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ Some(10_000), - ) - .await?; - let new_goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Write a concise benchmark summary", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ Some(10_000), - ) - .await?; - - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal: new_goal, - previous_status: ExternalGoalPreviousStatus::from(&old_goal), - }, - }) - .await?; - - let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - assert!( - pending_input.iter().any(|item| { - matches!( - item, - TurnInput::ResponseItem(ResponseItem::Message { role, content, .. }) - if role == "user" - && content.iter().any(|content| matches!( - content, - ContentItem::InputText { text } - if text.starts_with("") - && text.trim_end().ends_with("") - && text.contains("The active thread goal objective was edited") - && text.contains("Write a concise benchmark summary") - )) - ) - }), - "expected objective-updated steering prompt in pending input: {pending_input:?}" - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Keep improving the benchmark", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ None, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal, - previous_status: ExternalGoalPreviousStatus::NewGoal, - }, - }) - .await?; - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 65, - cached_input_tokens: 10, - output_tokens: 40, - reasoning_output_tokens: 5, - total_tokens: 110, - }, - ) - .await; - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: tc.as_ref(), - tool_name: "shell_command", - }) - .await?; - - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); - assert_eq!(25, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a report","token_budget":500}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed_with_tokens("resp-2", /*total_tokens*/ 580), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Goal complete."), - ev_completed("resp-3"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a report".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - return anyhow::Ok(()); - } - } - }) - .await??; - - let complete_output = responses - .function_call_output_text("call-complete-goal") - .expect("complete tool output should be sent to the model"); - let complete_output: serde_json::Value = serde_json::from_str(&complete_output)?; - assert_eq!(complete_output["goal"]["tokensUsed"], 580); - assert_eq!(complete_output["goal"]["status"], "complete"); - assert_eq!(complete_output["remainingTokens"], 0); - assert_eq!( - complete_output["completionBudgetReport"], - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - ); - let requests = responses.requests(); - let completion_followup_request = requests - .last() - .expect("completion tool output should be sent in a follow-up request"); - assert!( - !completion_followup_request.body_contains_text("budget_limited"), - "completion follow-up should not include budget-limit steering" - ); - - let state_db = codex_state::StateRuntime::init( - test.config.sqlite_home.clone(), - test.config.model_provider_id.clone(), - ) - .await?; - let persisted_goal = state_db - .thread_goals() - .get_thread_goal(test.session_configured.thread_id) - .await? - .expect("goal should be persisted"); - assert_eq!( - codex_state::ThreadGoalStatus::Complete, - persisted_goal.status - ); - assert_eq!(580, persisted_goal.tokens_used); - - Ok(()) -} - #[tokio::test] async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; @@ -10504,297 +9615,6 @@ async fn sample_rollout( ) } -#[tokio::test] -async fn create_goal_tool_rejects_existing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = CreateGoalHandler; - - handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal-1".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "create-goal-2".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Replace the watcher", - "token_budget": 456, - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected create_goal to reject an existing goal"); - }; - assert_eq!( - output, - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.objective, "Keep the watcher alive"); - assert_eq!(goal.token_budget, Some(123)); -} - -#[tokio::test] -async fn update_goal_tool_rejects_pausing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "pause-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "paused", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected update_goal to reject pausing a goal"); - }; - assert_eq!( - output, - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Active); -} - -#[tokio::test] -async fn update_goal_tool_marks_goal_blocked() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "block-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "blocked", - }) - .to_string(), - }, - }) - .await - .expect("update_goal should mark the goal blocked"); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Blocked); -} - -#[tokio::test] -async fn update_goal_tool_rejects_usage_limited_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "usage-limit-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "usageLimited", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected update_goal to reject usage-limiting a goal"); - }; - assert_eq!( - output, - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Active); -} - -#[tokio::test] -async fn update_goal_tool_marks_goal_complete() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "complete-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "complete", - }) - .to_string(), - }, - }) - .await - .expect("update_goal should mark the goal complete"); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Complete); -} - #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { use crate::exec_policy::ExecApprovalRequest; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 76303345a7..9d4e7d1e82 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -18,7 +18,6 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; @@ -151,15 +150,6 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } error!("Failed to run pre-sampling compact"); return None; } @@ -294,17 +284,6 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!( - "failed to usage-limit active goal after usage-limit error: {err}" - ); - } return None; } can_drain_pending_input = !model_needs_follow_up; @@ -390,15 +369,6 @@ pub(crate) async fn run_turn( let error = e.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 66a32f2aa6..5f52912d59 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -91,7 +91,6 @@ pub struct TurnContext { pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) available_models: Vec, pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, - pub(crate) goal_tools_supported: bool, pub features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, @@ -170,10 +169,6 @@ impl TurnContext { ToolEnvironmentMode::from_count(self.environments.turn_environments.len()) } - pub(crate) fn goal_tools_enabled(&self) -> bool { - self.goal_tools_supported && self.features.get().enabled(Feature::Goals) - } - pub(crate) async fn with_model( &self, model: String, @@ -264,7 +259,6 @@ impl TurnContext { shell_environment_policy: self.shell_environment_policy.clone(), available_models, unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), - goal_tools_supported: self.goal_tools_supported, features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), @@ -477,7 +471,6 @@ impl Session { cwd: AbsolutePathBuf, sub_id: String, skills_outcome: Arc, - goal_tools_supported: bool, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -568,7 +561,6 @@ impl Session { shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), available_models, unified_exec_shell_mode, - goal_tools_supported, features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, @@ -781,7 +773,6 @@ impl Session { .skills_for_config(&skills_input, fs) .await, ); - let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -810,7 +801,6 @@ impl Session { cwd, sub_id, skills_outcome, - goal_tools_supported, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 70466c9c2f..a9a664a505 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -23,7 +23,6 @@ use tracing::warn; use crate::config::Config; use crate::context::ContextualUserFragment; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; @@ -342,15 +341,6 @@ impl Session { .await .clear_turn(&turn_context.sub_id); - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: turn_context.as_ref(), - token_usage: token_usage_at_turn_start.clone(), - }) - .await - { - warn!("failed to apply goal runtime turn-start event: {err}"); - } let pending_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; @@ -504,15 +494,6 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; } - if (aborted_turn || reason == TurnAbortReason::Interrupted) - && let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { - turn_context: turn_context.as_deref(), - }) - .await - { - warn!("failed to apply goal runtime abort event: {err}"); - } if let Some(active_turn) = active_turn_to_clear { // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. @@ -553,14 +534,6 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; } - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { - turn_context: turn_context.as_deref(), - }) - .await - { - warn!("failed to apply goal runtime abort event: {err}"); - } // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. self.input_queue.clear_pending(&active_turn).await; @@ -760,15 +733,6 @@ impl Session { }); self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) .await; - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TurnFinished { - turn_context: turn_context.as_ref(), - turn_completed: true, - }) - .await - { - warn!("failed to apply goal runtime turn-finished event: {err}"); - } let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, @@ -798,12 +762,6 @@ impl Session { if !cleared_active_turn { return; } - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await - { - warn!("failed to apply goal runtime maybe-continue event: {err}"); - } self.emit_thread_idle_lifecycle_if_idle().await; } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d130a35815..1cde8c9320 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1343,9 +1343,6 @@ impl ThreadManagerState { .await?; if is_resumed_thread { new_thread.thread.emit_thread_resume_lifecycle().await; - if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { - warn!("failed to apply goal resume runtime effects: {err}"); - } } Ok(new_thread) } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 52a0f8eb3b..34ec682110 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -8,7 +8,6 @@ use crate::session::tests::make_session_and_context; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; use codex_extension_api::empty_extension_registry; -use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -1501,103 +1500,3 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ 1, ); } - -#[tokio::test] -async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { - let temp_dir = tempdir().expect("tempdir"); - let mut config = test_config().await; - config.codex_home = temp_dir.path().join("codex-home").abs(); - config.cwd = config.codex_home.abs(); - config - .features - .enable(Feature::Goals) - .expect("goals should be enableable in tests"); - std::fs::create_dir_all(&config.codex_home).expect("create codex home"); - - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let state_db = init_state_db(&config).await; - let manager = ThreadManager::new( - &config, - auth_manager.clone(), - SessionSource::Exec, - Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - empty_extension_registry(), - /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), - TEST_INSTALLATION_ID.to_string(), - /*attestation_provider*/ None, - ); - - let source = manager - .resume_thread_with_history( - config.clone(), - InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("keep working"))]), - auth_manager.clone(), - /*parent_trace*/ None, - ) - .await - .expect("create source thread"); - let source_path = source - .thread - .rollout_path() - .expect("source rollout path should exist"); - source.thread.flush_rollout().await?; - let state_db = source - .thread - .state_db() - .expect("source thread should have a state db"); - state_db - .thread_goals() - .replace_thread_goal( - source.thread_id, - "Keep working until the task is done", - codex_state::ThreadGoalStatus::Paused, - /*token_budget*/ None, - ) - .await?; - source.thread.shutdown_and_wait().await?; - manager.remove_thread(&source.thread_id).await; - - let resumed = manager - .resume_thread_from_rollout( - config.clone(), - source_path, - auth_manager, - /*parent_trace*/ None, - ) - .await - .expect("resume source thread"); - let goal = state_db - .thread_goals() - .get_thread_goal(resumed.thread_id) - .await? - .expect("goal should still exist after resume"); - assert_eq!(codex_state::ThreadGoalStatus::Paused, goal.status); - assert!( - resumed - .thread - .codex - .session - .active_turn - .lock() - .await - .is_none() - ); - - resumed.thread.continue_active_goal_if_idle().await?; - assert!( - resumed - .thread - .codex - .session - .active_turn - .lock() - .await - .is_none() - ); - - resumed.thread.shutdown_and_wait().await?; - Ok(()) -} diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs deleted file mode 100644 index 694eafca65..0000000000 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Built-in model tool handlers for persisted thread goals. -//! -//! The public tool contract intentionally splits goal creation from stopped -//! status updates: `create_goal` starts an active objective, while -//! `update_goal` can only mark the existing goal complete or blocked. - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use codex_protocol::protocol::ThreadGoal; -use codex_protocol::protocol::ThreadGoalStatus; -use serde::Deserialize; -use serde::Serialize; -use std::fmt::Write as _; - -mod create_goal; -mod get_goal; -mod update_goal; - -pub use create_goal::CreateGoalHandler; -pub use get_goal::GetGoalHandler; -pub use update_goal::UpdateGoalHandler; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CreateGoalArgs { - objective: String, - token_budget: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct UpdateGoalArgs { - status: ThreadGoalStatus, -} - -#[derive(Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GoalToolResponse { - goal: Option, - remaining_tokens: Option, - completion_budget_report: Option, -} - -#[derive(Clone, Copy)] -enum CompletionBudgetReport { - Include, - Omit, -} - -impl GoalToolResponse { - fn new(goal: Option, report_mode: CompletionBudgetReport) -> Self { - let remaining_tokens = goal.as_ref().and_then(|goal| { - goal.token_budget - .map(|budget| (budget - goal.tokens_used).max(0)) - }); - let completion_budget_report = match report_mode { - CompletionBudgetReport::Include => goal - .as_ref() - .filter(|goal| goal.status == ThreadGoalStatus::Complete) - .and_then(completion_budget_report), - CompletionBudgetReport::Omit => None, - }; - Self { - goal, - remaining_tokens, - completion_budget_report, - } - } -} - -fn format_goal_error(err: anyhow::Error) -> String { - let mut message = err.to_string(); - for cause in err.chain().skip(1) { - let _ = write!(message, ": {cause}"); - } - message -} - -fn goal_response( - goal: Option, - completion_budget_report: CompletionBudgetReport, -) -> Result { - let response = - serde_json::to_string_pretty(&GoalToolResponse::new(goal, completion_budget_report)) - .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; - Ok(FunctionToolOutput::from_text(response, Some(true))) -} - -fn completion_budget_report(goal: &ThreadGoal) -> Option { - if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { - None - } else { - Some( - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - .to_string(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::ThreadId; - use pretty_assertions::assert_eq; - - #[test] - fn completed_budgeted_goal_response_reports_final_usage() { - let goal = ThreadGoal { - thread_id: ThreadId::new(), - objective: "Keep optimizing".to_string(), - status: ThreadGoalStatus::Complete, - token_budget: Some(10_000), - tokens_used: 3_250, - time_used_seconds: 75, - created_at: 1, - updated_at: 2, - }; - - let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); - - assert_eq!( - response, - GoalToolResponse { - goal: Some(goal), - remaining_tokens: Some(6_750), - completion_budget_report: Some( - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - .to_string() - ), - } - ); - } - - #[test] - fn completed_unbudgeted_goal_response_omits_budget_report() { - let goal = ThreadGoal { - thread_id: ThreadId::new(), - objective: "Write a poem".to_string(), - status: ThreadGoalStatus::Complete, - token_budget: None, - tokens_used: 120, - time_used_seconds: 0, - created_at: 1, - updated_at: 2, - }; - - let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); - - assert_eq!( - response, - GoalToolResponse { - goal: Some(goal), - remaining_tokens: None, - completion_budget_report: None, - } - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs deleted file mode 100644 index 1791a5e85f..0000000000 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::goals::CreateGoalRequest; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_create_goal_tool; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::CreateGoalArgs; -use super::format_goal_error; -use super::goal_response; - -pub struct CreateGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for CreateGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(CREATE_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_create_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "goal handler received unsupported payload".to_string(), - )); - } - }; - - let args: CreateGoalArgs = parse_arguments(&arguments)?; - let goal = session - .create_thread_goal( - turn.as_ref(), - CreateGoalRequest { - objective: args.objective, - token_budget: args.token_budget, - }, - ) - .await - .map_err(|err| { - if err - .chain() - .any(|cause| cause.to_string().contains("already has a goal")) - { - FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - .to_string(), - ) - } else { - FunctionCallError::RespondToModel(format_goal_error(err)) - } - })?; - goal_response(Some(goal), CompletionBudgetReport::Omit).map(boxed_tool_output) - } -} - -impl CoreToolRuntime for CreateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs deleted file mode 100644 index ff46070675..0000000000 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_get_goal_tool; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::format_goal_error; -use super::goal_response; - -pub struct GetGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for GetGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(GET_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_get_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, payload, .. - } = invocation; - - match payload { - ToolPayload::Function { .. } => { - let goal = session - .get_thread_goal() - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit).map(boxed_tool_output) - } - _ => Err(FunctionCallError::RespondToModel( - "get_goal handler received unsupported payload".to_string(), - )), - } - } -} - -impl CoreToolRuntime for GetGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs deleted file mode 100644 index 373c849fc2..0000000000 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ /dev/null @@ -1,89 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_update_goal_tool; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_protocol::protocol::ThreadGoalStatus; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::UpdateGoalArgs; -use super::format_goal_error; -use super::goal_response; - -pub struct UpdateGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for UpdateGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(UPDATE_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_update_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "update_goal handler received unsupported payload".to_string(), - )); - } - }; - - let args: UpdateGoalArgs = parse_arguments(&arguments)?; - if !matches!( - args.status, - ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked - ) { - return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - .to_string(), - )); - } - session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { - turn_context: turn.as_ref(), - }) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let goal = session - .set_thread_goal( - turn.as_ref(), - SetGoalRequest { - objective: None, - status: Some(args.status), - token_budget: None, - }, - ) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let completion_budget_report = if args.status == ThreadGoalStatus::Complete { - CompletionBudgetReport::Include - } else { - CompletionBudgetReport::Omit - }; - goal_response(Some(goal), completion_budget_report).map(boxed_tool_output) - } -} - -impl CoreToolRuntime for UpdateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs deleted file mode 100644 index da8e23d305..0000000000 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Responses API tool definitions for persisted thread goals. -//! -//! These specs expose goal read/update primitives to the model while keeping -//! usage accounting system-managed. - -use codex_tools::JsonSchema; -use codex_tools::ResponsesApiTool; -use codex_tools::ToolSpec; -use serde_json::json; -use std::collections::BTreeMap; - -pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; -pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; -pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; - -pub fn create_get_goal_tool() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: GET_GOAL_TOOL_NAME.to_string(), - description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), - output_schema: None, - }) -} - -pub fn create_create_goal_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "objective".to_string(), - JsonSchema::string(Some( - "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." - .to_string(), - )), - ), - ( - "token_budget".to_string(), - JsonSchema::integer(Some( - "Positive token budget for the new goal. Omit unless explicitly requested." - .to_string(), - )), - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: CREATE_GOAL_TOOL_NAME.to_string(), - description: format!( - r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. -Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# - ), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - /*required*/ Some(vec!["objective".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - -pub fn create_update_goal_tool() -> ToolSpec { - let properties = BTreeMap::from([( - "status".to_string(), - JsonSchema::string_enum( - vec![json!("complete"), json!("blocked")], - Some( - "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." - .to_string(), - ), - ), - )]); - - ToolSpec::Function(ResponsesApiTool { - name: UPDATE_GOAL_TOOL_NAME.to_string(), - description: r#"Update the existing goal. -Use this tool only to mark the goal achieved or genuinely blocked. -Set status to `complete` only when the objective has actually been achieved and no required work remains. -Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. -If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. -Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. -Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. -Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. -When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - /*required*/ Some(vec!["status".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn update_goal_tool_exposes_complete_and_blocked_statuses() { - let ToolSpec::Function(tool) = create_update_goal_tool() else { - panic!("update_goal should be a function tool"); - }; - let status = tool - .parameters - .properties - .as_ref() - .and_then(|properties| properties.get("status")) - .expect("status property should exist"); - - assert_eq!( - status.enum_values, - Some(vec![json!("complete"), json!("blocked")]) - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d6963f0761..fdfb809965 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -4,8 +4,6 @@ pub(crate) mod apply_patch; pub(crate) mod apply_patch_spec; mod dynamic; pub(crate) mod extension_tools; -mod goal; -pub(crate) mod goal_spec; mod list_available_plugins_to_install; pub(crate) mod list_available_plugins_to_install_spec; mod mcp; @@ -53,9 +51,6 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use goal::CreateGoalHandler; -pub use goal::GetGoalHandler; -pub use goal::UpdateGoalHandler; pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; pub use mcp::McpHandler; pub use mcp_resource::ListMcpResourceTemplatesHandler; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b2a0b7704a..1cc5e67eb5 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,7 +5,6 @@ use std::sync::atomic::Ordering; use std::time::Duration; use crate::function_tool::FunctionCallError; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PreToolUseHookResult; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::run_post_tool_use_hooks; @@ -34,7 +33,6 @@ use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; use serde_json::Value; -use tracing::warn; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; @@ -649,25 +647,13 @@ impl ToolRegistry { handler_executed: true, }, }; - let finished = notify_tool_finish_if_unclaimed( + notify_tool_finish_if_unclaimed( &invocation, terminal_outcome_reached.as_deref(), lifecycle_outcome, ) .await; - if finished - && let Err(err) = invocation - .session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: invocation.turn.as_ref(), - tool_name: tool_name.name.as_str(), - }) - .await - { - warn!("failed to account thread goal progress after tool call: {err}"); - } - match result { Ok(_) => { let mut guard = response_cell.lock().await; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 47d2ac6726..514432c880 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -6,11 +6,9 @@ use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; -use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; -use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; @@ -24,7 +22,6 @@ use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; -use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; @@ -305,14 +302,6 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { } } -fn goal_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.goal_tools_enabled() - && !matches!( - turn_context.session_source, - SessionSource::SubAgent(SubAgentSource::Review) - ) -} - fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) } @@ -558,8 +547,8 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann add_core_utility_tools(context, planned_tools); add_collaboration_tools(context, planned_tools); add_mcp_runtime_tools(context, planned_tools); - add_dynamic_tools(context, planned_tools); add_extension_tools(context, planned_tools); + add_dynamic_tools(context, planned_tools); for spec in hosted_model_tool_specs(context) { planned_tools.add_hosted_spec(spec); } @@ -639,11 +628,6 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut let environment_mode = turn_context.tool_environment_mode(); planned_tools.add(PlanHandler); - if goal_tools_enabled(turn_context) { - planned_tools.add(GetGoalHandler); - planned_tools.add(CreateGoalHandler); - planned_tools.add(UpdateGoalHandler); - } if turn_context.config.experimental_request_user_input_enabled { planned_tools.add(RequestUserInputHandler { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 17dfdc7a74..5fe811b19e 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -619,36 +619,7 @@ async fn environment_count_controls_environment_backed_tools() { } #[tokio::test] -async fn host_context_gates_goal_and_agent_job_tools() { - let feature_disabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ false); - turn.goal_tools_supported = true; - }) - .await; - feature_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - - let host_disabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = false; - }) - .await; - host_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - - let enabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = true; - }) - .await; - enabled.assert_visible_contains(&["get_goal", "create_goal", "update_goal"]); - - let review_thread = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = true; - turn.session_source = SessionSource::SubAgent(SubAgentSource::Review); - }) - .await; - review_thread.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - +async fn host_context_gates_agent_job_tools() { let normal_agent_job = probe(|turn| { set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); }) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index dddfd4af68..640c1c1db1 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -188,9 +188,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }; expected_tools_names.extend([ "update_plan", - "get_goal", - "create_goal", - "update_goal", "request_user_input", "apply_patch", "view_image", From 470c20bf98ce35c8bcaa021e722f0a7074c4530b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 5 Jun 2026 15:41:13 -0700 Subject: [PATCH 088/141] protocol: remove submission-side serde from Op (#26674) ## Why Submission-side `Op` payloads are now an internal handoff inside the Rust codebase, so keeping a stable serde contract there adds complexity without a real wire consumer. ## What changed - remove serde/schema annotations from `Submission`, `Op`, and submission-only payload types like thread settings overrides, additional context, realtime conversation params, `TurnEnvironmentSelection`, and `RequestUserInputResponse` - delete the `Op` serialization tests and the now-unused double-option prompt serde helper - keep event/API-facing serialization where it is still required, and serialize the `request_user_input` tool output from its wire payload instead of the core response struct - update `protocol_v1.md` to call out that events remain the serialized transport surface while submission payloads are implementation details ## Testing - `just test -p codex-protocol` - `cargo check -p codex-core -p codex-app-server -p codex-thread-store` - `just test -p codex-core request_user_input` --- codex-rs/docs/protocol_v1.md | 3 +- codex-rs/protocol/src/protocol.rs | 322 +----------------------------- 2 files changed, 13 insertions(+), 312 deletions(-) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 464e8187b4..f16da209f6 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -54,6 +54,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t - These are messages sent on the `SQ` (UI -> `Codex`) - Has an string ID provided by the UI, referred to as `sub_id` - `Op` refers to the enum of all possible `Submission` payloads + - In the current codebase these are primarily in-process Rust types rather than a stable serde wire contract - This enum is `non_exhaustive`; variants can be added at future dates - `Event` - These are messages sent on the `EQ` (`Codex` -> UI) @@ -103,7 +104,7 @@ The `response_id` returned from each turn matches the OpenAI `response_id` store Can operate over any transport that supports bi-directional streaming. - cross-thread channels - IPC channels - stdin/stdout - TCP - HTTP2 - gRPC -Non-framed transports, such as stdin/stdout and TCP, should use newline-delimited JSON in sending messages. +Events still serialize cleanly to newline-delimited JSON for non-framed transports, such as stdin/stdout and TCP. Submission payloads should be treated as implementation details unless a specific transport owns an explicit adapter. ## Example Flows diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cd183dae0c..c9e2e8bb28 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -106,7 +106,7 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, PartialEq)] pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: AbsolutePathBuf, @@ -124,17 +124,15 @@ impl GitSha { } /// Submission Queue Entry - requests from user -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Clone)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, /// Payload pub op: Op, /// Client-provided id for the user message represented by `Op::UserInput`. - #[serde(default, skip_serializing_if = "Option::is_none")] pub client_user_message_id: Option, /// Optional W3C trace carrier propagated across async submission handoffs. - #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option, } @@ -149,34 +147,23 @@ pub struct W3cTraceContext { } /// Config payload for refreshing MCP servers. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, PartialEq)] pub struct McpServerRefreshConfig { pub mcp_servers: Value, pub mcp_oauth_credentials_store_mode: Value, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationStartParams { /// Selects whether the realtime session should produce text or audio output. pub output_modality: RealtimeOutputModality, - #[serde( - default, - deserialize_with = "conversation_start_prompt_serde::deserialize", - serialize_with = "conversation_start_prompt_serde::serialize", - skip_serializing_if = "Option::is_none" - )] pub prompt: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub realtime_session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub transport: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub voice: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] +#[derive(Debug, Clone, PartialEq)] pub enum ConversationStartTransport { Websocket, Webrtc { sdp: String }, @@ -189,28 +176,6 @@ pub enum RealtimeOutputModality { Audio, } -mod conversation_start_prompt_serde { - use serde::Deserializer; - use serde::Serializer; - - pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> - where - D: Deserializer<'de>, - { - serde_with::rust::double_option::deserialize(deserializer) - } - - pub(crate) fn serialize( - value: &Option>, - serializer: S, - ) -> Result - where - S: Serializer, - { - serde_with::rust::double_option::serialize(value, serializer) - } -} - #[derive( Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS, Ord, PartialOrd, )] @@ -391,109 +356,92 @@ pub enum RealtimeEvent { Error(String), } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationAudioParams { pub frame: RealtimeAudioFrame, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationTextParams { pub text: String, } /// Persistent thread-settings overrides that can be applied before user input or /// on their own. -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. - #[serde(skip_serializing_if = "Option::is_none")] pub workspace_roots: Option>, /// Updated profile-defined workspace roots for status summaries and /// per-turn config reconstruction. - #[serde(skip_serializing_if = "Option::is_none")] pub profile_workspace_roots: Option>, /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] pub approval_policy: Option, /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] pub approvals_reviewer: Option, /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_policy: Option, /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub permission_profile: Option, /// Named or built-in profile that produced `permission_profile`, if the /// update selected a profile rather than supplying raw permissions. - #[serde(skip_serializing_if = "Option::is_none")] pub active_permission_profile: Option, /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] pub windows_sandbox_level: Option, /// Updated model slug. When set, the model info is derived automatically. - #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Updated reasoning effort (honored only for reasoning-capable models). /// /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the /// effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option>, /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, /// Updated service tier preference for future turns. /// /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, } /// Source classification for client-supplied context. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdditionalContextKind { Untrusted, Application, } /// Client-supplied context keyed by an opaque source identifier. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AdditionalContextEntry { pub value: String, pub kind: AdditionalContextKind, } /// Submission operation -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] #[non_exhaustive] pub enum Op { @@ -525,20 +473,15 @@ pub enum Op { /// User input items, see `InputItem` items: Vec, /// Optional turn-scoped environments. - #[serde(default, skip_serializing_if = "Option::is_none")] environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. - #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, /// Optional turn-scoped Responses API `client_metadata`. - #[serde(default, skip_serializing_if = "Option::is_none")] responsesapi_client_metadata: Option>, /// Client-supplied context fragments keyed by an opaque source identifier. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] additional_context: BTreeMap, /// Persistent thread-settings overrides to apply before the input. - #[serde(default, flatten)] thread_settings: ThreadSettingsOverrides, }, @@ -548,7 +491,6 @@ pub enum Op { /// preserve caller order between both kinds of mutation. ThreadSettings { /// Persistent thread-settings overrides to apply. - #[serde(flatten)] thread_settings: ThreadSettingsOverrides, }, @@ -563,7 +505,6 @@ pub enum Op { /// The id of the submission we are approving id: String, /// Turn id associated with the approval event, when available. - #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, /// The user's decision in response to the request. decision: ReviewDecision, @@ -586,15 +527,12 @@ pub enum Op { /// User's decision for the request. decision: ElicitationAction, /// Structured user input supplied for accepted elicitations. - #[serde(default, skip_serializing_if = "Option::is_none")] content: Option, /// Optional client metadata associated with the elicitation response. - #[serde(default, skip_serializing_if = "Option::is_none")] meta: Option, }, /// Resolve a request_user_input tool call. - #[serde(rename = "user_input_answer", alias = "request_user_input_response")] UserInputAnswer { /// Turn id for the in-flight request. id: String, @@ -4950,146 +4888,6 @@ mod tests { assert!(event.affects_turn_status()); } - #[test] - fn conversation_op_serializes_as_unnested_variants() { - let audio = Op::RealtimeConversationAudio(ConversationAudioParams { - frame: RealtimeAudioFrame { - data: "AQID".to_string(), - sample_rate: 24_000, - num_channels: 1, - samples_per_channel: Some(480), - item_id: None, - }, - }); - let start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(Some("be helpful".to_string())), - realtime_session_id: Some("conv_1".to_string()), - transport: None, - voice: None, - }); - let webrtc_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(Some("be helpful".to_string())), - realtime_session_id: Some("conv_1".to_string()), - transport: Some(ConversationStartTransport::Webrtc { - sdp: "v=offer\r\n".to_string(), - }), - voice: Some(RealtimeVoice::Cove), - }); - let text = Op::RealtimeConversationText(ConversationTextParams { - text: "hello".to_string(), - }); - let close = Op::RealtimeConversationClose; - let default_prompt_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: None, - realtime_session_id: None, - transport: None, - voice: None, - }); - let null_prompt_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(None), - realtime_session_id: None, - transport: None, - voice: None, - }); - let list_voices = Op::RealtimeConversationListVoices; - - assert_eq!( - serde_json::to_value(&start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": "be helpful", - "realtime_session_id": "conv_1" - }) - ); - assert_eq!( - serde_json::to_value(&default_prompt_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio" - }) - ); - assert_eq!( - serde_json::to_value(&null_prompt_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": null - }) - ); - assert_eq!( - serde_json::from_value::(json!({ - "type": "realtime_conversation_start", - "output_modality": "audio" - })) - .unwrap(), - default_prompt_start - ); - assert_eq!( - serde_json::from_value::(json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": null - })) - .unwrap(), - null_prompt_start - ); - assert_eq!( - serde_json::to_value(&audio).unwrap(), - json!({ - "type": "realtime_conversation_audio", - "frame": { - "data": "AQID", - "sample_rate": 24000, - "num_channels": 1, - "samples_per_channel": 480 - } - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&text).unwrap()).unwrap(), - text - ); - assert_eq!( - serde_json::to_value(&close).unwrap(), - json!({ - "type": "realtime_conversation_close" - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&close).unwrap()).unwrap(), - close - ); - assert_eq!( - serde_json::to_value(&list_voices).unwrap(), - json!({ - "type": "realtime_conversation_list_voices" - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&list_voices).unwrap()).unwrap(), - list_voices - ); - assert_eq!( - serde_json::to_value(&webrtc_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": "be helpful", - "realtime_session_id": "conv_1", - "transport": { - "type": "webrtc", - "sdp": "v=offer\r\n" - }, - "voice": "cove" - }) - ); - } - #[test] fn realtime_conversation_started_event_uses_realtime_session_id() { let event = RealtimeConversationStartedEvent { @@ -5140,104 +4938,6 @@ mod tests { ); } - #[test] - fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(op)?; - assert_eq!(json_op, json!({ "type": "user_input", "items": [] })); - - Ok(()) - } - - #[test] - fn user_input_deserializes_without_final_output_json_schema_field() -> Result<()> { - let op: Op = serde_json::from_value(json!({ "type": "user_input", "items": [] }))?; - - assert_eq!( - op, - Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - } - ); - - Ok(()) - } - - #[test] - fn user_input_serialization_includes_final_output_json_schema_when_some() -> Result<()> { - let schema = json!({ - "type": "object", - "properties": { - "answer": { "type": "string" } - }, - "required": ["answer"], - "additionalProperties": false - }); - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: Some(schema.clone()), - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(op)?; - assert_eq!( - json_op, - json!({ - "type": "user_input", - "items": [], - "final_output_json_schema": schema, - }) - ); - - Ok(()) - } - - #[test] - fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> { - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: Some(HashMap::from([( - "fiber_run_id".to_string(), - "fiber-123".to_string(), - )])), - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(&op)?; - assert_eq!( - json_op, - json!({ - "type": "user_input", - "items": [], - "responsesapi_client_metadata": { - "fiber_run_id": "fiber-123", - } - }) - ); - assert_eq!(serde_json::from_value::(json_op)?, op); - - Ok(()) - } - #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { From c62d79259d0ccd83ad515d3444840e0e0e18059a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 5 Jun 2026 15:54:46 -0700 Subject: [PATCH 089/141] Block active goals after terminal turn errors (#26690) ## Why Terminal turn errors can leave a goal active. Automatic goal continuation may then repeatedly hit a permanent failure, including compaction requests rejected with HTTP 400, and consume excessive tokens. This PR changes the goal extension to treat all turn-ending errors (including non-retryable errors and retryable errors that have exceeded their retry count) as "blocking" for the goal. The downside to this change is that there are some errors that may eventually succeed (e.g. a 429 due to a service outage), and previously the goal runtime would have kept the agent going in these situations. ## What changed - Block the current active goal when a turn ends with an error other than a usage-limit error. - Preserve the existing `usage_limited` transition for usage-limit errors. - Share progress accounting, guarded state updates, metrics, and event emission in the goal runtime. --- codex-rs/ext/goal/src/extension.rs | 19 ++++-- codex-rs/ext/goal/src/runtime.rs | 63 ++++++++++++++++--- .../ext/goal/tests/goal_extension_backend.rs | 60 ++++++++++++++---- 3 files changed, 118 insertions(+), 24 deletions(-) diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 563c40b503..78ae0b5b44 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -36,6 +36,7 @@ use crate::accounting::GoalAccountingState; use crate::api::GoalService; use crate::events::GoalEventEmitter; use crate::metrics::GoalMetrics; +use crate::runtime::ActiveGoalStopReason; use crate::runtime::GoalRuntimeConfig; use crate::runtime::GoalRuntimeHandle; use crate::spec::UPDATE_GOAL_TOOL_NAME; @@ -278,18 +279,26 @@ where } async fn on_turn_error(&self, input: TurnErrorInput<'_>) { - if input.error != CodexErrorInfo::UsageLimitExceeded { - return; - } let Some(runtime) = goal_runtime_handle(input.thread_store) else { return; }; + let reason = match input.error { + CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit, + // The turn has ended because the error was non-retryable or its + // retries were exhausted. Block the goal to prevent automatic + // continuation from looping and consuming tokens, as can happen + // with compaction errors. + _ => ActiveGoalStopReason::TurnError, + }; if let Err(err) = runtime - .usage_limit_active_goal_for_turn(input.turn_id) + .stop_active_goal_for_turn(input.turn_id, reason) .await { - tracing::warn!("failed to usage-limit active goal after usage-limit error: {err}"); + tracing::warn!( + error = ?input.error, + "failed to stop active goal after turn error: {err}" + ); } } } diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 9818282cae..2641dfb949 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -28,6 +28,11 @@ pub(crate) struct GoalRuntimeConfig { pub(crate) tools_available_for_thread: bool, } +pub(crate) enum ActiveGoalStopReason { + TurnError, + UsageLimit, +} + struct GoalRuntimeInner { thread_id: ThreadId, state_dbs: Arc, @@ -216,10 +221,23 @@ impl GoalRuntimeHandle { } pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { + self.stop_active_goal_for_turn(turn_id, ActiveGoalStopReason::UsageLimit) + .await + } + + /// Accounts the ending turn and stops its active goal after a terminal error. + pub(crate) async fn stop_active_goal_for_turn( + &self, + turn_id: &str, + reason: ActiveGoalStopReason, + ) -> Result<(), String> { if !self.is_enabled() { return Ok(()); } + // Hold this through accounting and the status update so external goal + // mutations and idle continuation cannot interleave between them. + let _goal_state_permit = self.goal_state_permit().await?; if !self .inner .accounting_state @@ -228,23 +246,54 @@ impl GoalRuntimeHandle { return Ok(()); } - let progress_event_id = format!("{turn_id}:usage-limit-progress"); + let (event_name, status) = match reason { + ActiveGoalStopReason::TurnError => { + ("turn-error", codex_state::ThreadGoalStatus::Blocked) + } + ActiveGoalStopReason::UsageLimit => { + ("usage-limit", codex_state::ThreadGoalStatus::UsageLimited) + } + }; self.account_active_goal_progress( turn_id, - progress_event_id.as_str(), + &format!("{turn_id}:{event_name}-progress"), codex_state::GoalAccountingMode::ActiveOnly, BudgetLimitedGoalDisposition::ClearActive, ) .await?; - let previous_status = self - .current_goal_status_for_metrics(/*expected_goal_id*/ None) - .await?; + let Some(active_goal) = self + .inner + .state_dbs + .thread_goals() + .get_thread_goal(self.thread_id()) + .await + .map_err(|err| err.to_string())? + else { + self.inner.accounting_state.clear_active_goal(); + return Ok(()); + }; + let can_stop = active_goal.status == codex_state::ThreadGoalStatus::Active + || (active_goal.status == codex_state::ThreadGoalStatus::BudgetLimited + && status == codex_state::ThreadGoalStatus::UsageLimited); + if !can_stop { + self.inner.accounting_state.clear_active_goal(); + return Ok(()); + } + let previous_status = Some(active_goal.status); let Some(goal) = self .inner .state_dbs .thread_goals() - .usage_limit_active_thread_goal(self.thread_id()) + .update_thread_goal( + self.thread_id(), + codex_state::GoalUpdate { + objective: None, + status: Some(status), + token_budget: None, + expected_goal_id: Some(active_goal.goal_id), + }, + ) .await .map_err(|err| err.to_string())? else { @@ -256,7 +305,7 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); let goal = protocol_goal_from_state(goal); self.inner.event_emitter.thread_goal_updated( - format!("{turn_id}:usage-limit"), + format!("{turn_id}:{event_name}"), Some(turn_id.to_string()), goal, ); diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 3c437a0889..f89a08ebbb 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -494,18 +494,9 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any ), ) .await; - let turn_store = ExtensionData::new("turn-1"); - for contributor in harness.registry.turn_lifecycle_contributors() { - contributor - .on_turn_error(TurnErrorInput { - turn_id: "turn-1", - error: CodexErrorInfo::UsageLimitExceeded, - session_store: &harness.session_store, - thread_store: &harness.thread_store, - turn_store: &turn_store, - }) - .await; - } + harness + .notify_turn_error("turn-1", CodexErrorInfo::UsageLimitExceeded) + .await; let goal = runtime .thread_goals() @@ -557,6 +548,36 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any Ok(()) } +#[tokio::test] +async fn turn_error_blocks_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + tool_by_name(&tools, "create_goal") + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .notify_turn_error("turn-1", CodexErrorInfo::Other) + .await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Blocked, goal.status); + Ok(()) +} + #[tokio::test] async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -1255,6 +1276,21 @@ impl GoalExtensionHarness { } } + async fn notify_turn_error(&self, turn_id: &str, error: CodexErrorInfo) { + let turn_store = ExtensionData::new(turn_id); + for contributor in self.registry.turn_lifecycle_contributors() { + contributor + .on_turn_error(TurnErrorInput { + turn_id, + error: error.clone(), + session_store: &self.session_store, + thread_store: &self.thread_store, + turn_store: &turn_store, + }) + .await; + } + } + fn runtime_handle(&self) -> Arc { self.thread_store .get::() From 4f655bc3b740bb1fec3744df4851f7eb1b95fbe9 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Jun 2026 16:33:01 -0700 Subject: [PATCH 090/141] [codex] Remove legacy remote plugin startup sync (#25936) ## Summary - Remove the legacy startup remote plugin sync path that called `/plugins/list` and reconciled curated plugin cache/config. - Remove the `sync_plugins_from_remote` API, its result/error types, startup marker task, and tests that expected the legacy request. - Keep the current remote installed bundle sync and remote catalog flows (`/ps/plugins/installed` and `/ps/plugins/list`) intact. ## Validation - `just fmt` - `git diff --check` - `env HOME=/private/tmp/codex-xin-build-home USERPROFILE=/private/tmp/codex-xin-build-home just test -p codex-core-plugins` - Searched for legacy `/plugins/list` sync references; remaining matches are `/ps/plugins/list` catalog tests/code. ## Notes - `just test -p codex-app-server plugin_list` is currently blocked before running filtered tests by an unrelated compile error in `app-server/tests/suite/v2/image_generation.rs`: `app_test_support::McpProcess` is not exported. --- .../app-server/tests/suite/v2/plugin_list.rs | 89 ---- codex-rs/core-plugins/src/lib.rs | 3 - codex-rs/core-plugins/src/manager.rs | 333 ------------- codex-rs/core-plugins/src/manager_tests.rs | 444 ------------------ codex-rs/core-plugins/src/remote_legacy.rs | 69 +-- .../core-plugins/src/startup_remote_sync.rs | 100 ---- .../src/startup_remote_sync_tests.rs | 91 ---- codex-rs/core-plugins/src/test_support.rs | 4 - 8 files changed, 3 insertions(+), 1130 deletions(-) delete mode 100644 codex-rs/core-plugins/src/startup_remote_sync.rs delete mode 100644 codex-rs/core-plugins/src/startup_remote_sync_tests.rs diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ae622db8a8..49ad3c418e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -38,7 +38,6 @@ use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; @@ -1460,94 +1459,6 @@ enabled = true Ok(()) } -#[tokio::test] -async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - write_openai_curated_marketplace(codex_home.path(), &["linear"])?; - - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/featured")) - .and(query_param("platform", "codex")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) - .mount(&server) - .await; - - let marker_path = codex_home - .path() - .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - - { - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - wait_for_path_exists(&marker_path).await?; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - marketplace_kinds: None, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let curated_marketplace = response - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("expected openai-curated marketplace entry"); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![("linear@openai-curated".to_string(), true, true)] - ); - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - } - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - - { - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - } - - tokio::time::sleep(Duration::from_millis(250)).await; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?; - Ok(()) -} - #[tokio::test] async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 98cb73e127..8cfb9092f6 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -11,7 +11,6 @@ mod plugin_bundle_archive; pub mod remote; pub mod remote_bundle; pub mod remote_legacy; -pub(crate) mod startup_remote_sync; pub mod startup_sync; pub mod store; #[cfg(test)] @@ -37,10 +36,8 @@ pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; -pub use manager::PluginRemoteSyncError; pub use manager::PluginUninstallError; pub use manager::PluginsConfigInput; pub use manager::PluginsManager; -pub use manager::RemotePluginSyncResult; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 845b8f3f32..1839b06e02 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,5 +1,4 @@ use super::PluginLoadOutcome; -use super::startup_remote_sync::start_startup_remote_plugin_sync_once; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; use crate::loader::PluginHookLoadOutcome; @@ -32,7 +31,6 @@ use crate::marketplace::ResolvedMarketplacePlugin; use crate::marketplace::find_installable_marketplace_plugin; use crate::marketplace::find_marketplace_plugin; use crate::marketplace::list_marketplaces; -use crate::marketplace::load_marketplace; use crate::marketplace::plugin_interface_with_marketplace_category; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; @@ -52,8 +50,6 @@ use crate::store::PluginStore; use crate::store::PluginStoreError; use codex_analytics::AnalyticsEventsClient; use codex_config::ConfigLayerStack; -use codex_config::PluginConfigEdit; -use codex_config::apply_user_plugin_config_edits; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; @@ -81,7 +77,6 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; use tokio::sync::Semaphore; -use tracing::info; use tracing::warn; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); @@ -297,107 +292,6 @@ impl From for PluginCapabilitySummary { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RemotePluginSyncResult { - /// Plugin ids newly installed into the local plugin cache. - pub installed_plugin_ids: Vec, - /// Plugin ids whose local config was changed to enabled. - pub enabled_plugin_ids: Vec, - /// Plugin ids whose local config was changed to disabled. - /// This is not populated by `sync_plugins_from_remote`. - pub disabled_plugin_ids: Vec, - /// Plugin ids removed from local cache or plugin config. - pub uninstalled_plugin_ids: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum PluginRemoteSyncError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] - Request { - url: String, - #[source] - source: reqwest::Error, - }, - - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] - UnexpectedStatus { - url: String, - status: reqwest::StatusCode, - body: String, - }, - - #[error("failed to parse remote plugin sync response from {url}: {source}")] - Decode { - url: String, - #[source] - source: serde_json::Error, - }, - - #[error("local curated marketplace is not available")] - LocalMarketplaceNotFound, - - #[error("remote marketplace `{marketplace_name}` is not available locally")] - UnknownRemoteMarketplace { marketplace_name: String }, - - #[error("duplicate remote plugin `{plugin_name}` in sync response")] - DuplicateRemotePlugin { plugin_name: String }, - - #[error( - "remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`" - )] - UnknownRemotePlugin { - plugin_name: String, - marketplace_name: String, - }, - - #[error("{0}")] - InvalidPluginId(#[from] PluginIdError), - - #[error("{0}")] - Marketplace(#[from] MarketplaceError), - - #[error("{0}")] - Store(#[from] PluginStoreError), - - #[error("{0}")] - Config(#[from] anyhow::Error), - - #[error("failed to join remote plugin sync task: {0}")] - Join(#[from] tokio::task::JoinError), -} - -impl PluginRemoteSyncError { - fn join(source: tokio::task::JoinError) -> Self { - Self::Join(source) - } -} - -impl From for PluginRemoteSyncError { - fn from(value: RemotePluginFetchError) -> Self { - match value { - RemotePluginFetchError::AuthRequired => Self::AuthRequired, - RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode, - RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source), - RemotePluginFetchError::Request { url, source } => Self::Request { url, source }, - RemotePluginFetchError::UnexpectedStatus { url, status, body } => { - Self::UnexpectedStatus { url, status, body } - } - RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source }, - } - } -} - pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, @@ -408,7 +302,6 @@ pub struct PluginsManager { enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, - remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, } @@ -462,7 +355,6 @@ impl PluginsManager { remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), - remote_sync_lock: Semaphore::new(/*permits*/ 1), restriction_product, analytics_events_client: RwLock::new(None), } @@ -1051,224 +943,6 @@ impl PluginsManager { Ok(()) } - pub async fn sync_plugins_from_remote( - &self, - config: &PluginsConfigInput, - auth: Option<&CodexAuth>, - additive_only: bool, - ) -> Result { - let _remote_sync_guard = self.remote_sync_lock.acquire().await.map_err(|_| { - PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed")) - })?; - - if !config.plugins_enabled { - return Ok(RemotePluginSyncResult::default()); - } - - info!("starting remote plugin sync"); - let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status( - &remote_plugin_service_config(config), - auth, - ) - .await - .map_err(PluginRemoteSyncError::from)?; - let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); - let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path()); - let curated_marketplace_path = AbsolutePathBuf::try_from( - curated_marketplace_root.join(".agents/plugins/marketplace.json"), - ) - .map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?; - let curated_marketplace = match load_marketplace(&curated_marketplace_path) { - Ok(marketplace) => marketplace, - Err(MarketplaceError::MarketplaceNotFound { .. }) => { - return Err(PluginRemoteSyncError::LocalMarketplaceNotFound); - } - Err(err) => return Err(err.into()), - }; - - let marketplace_name = curated_marketplace.name.clone(); - let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) - .ok_or_else(|| { - PluginStoreError::Invalid( - "local curated marketplace sha is not available".to_string(), - ) - })?; - let cache_plugin_version = curated_plugin_cache_version(&curated_plugin_version); - let mut local_plugins = Vec::<( - String, - PluginId, - AbsolutePathBuf, - Option, - Option, - bool, - )>::new(); - let mut local_plugin_names = HashSet::new(); - for plugin in curated_marketplace.plugins { - let plugin_name = plugin.name; - if !local_plugin_names.insert(plugin_name.clone()) { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "ignoring duplicate local plugin entry during remote sync" - ); - continue; - } - - let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?; - let plugin_key = plugin_id.as_key(); - let source_path = match plugin.source { - MarketplacePluginSource::Local { path } => path, - MarketplacePluginSource::Git { .. } => { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "skipping remote plugin source during remote sync" - ); - continue; - } - }; - let current_enabled = configured_plugins - .get(&plugin_key) - .map(|plugin| plugin.enabled); - let installed_version = self.store.active_plugin_version(&plugin_id); - let product_allowed = - self.restriction_product_matches(plugin.policy.products.as_deref()); - local_plugins.push(( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - )); - } - - let mut missing_remote_plugins = Vec::::new(); - let mut remote_installed_plugin_names = HashSet::::new(); - for plugin in remote_plugins { - if plugin.marketplace_name != marketplace_name { - return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { - marketplace_name: plugin.marketplace_name, - }); - } - if !local_plugin_names.contains(&plugin.name) { - missing_remote_plugins.push(plugin.name); - continue; - } - // For now, sync treats remote `enabled = false` as uninstall rather than a distinct - // disabled state. - // TODO: Switch sync to `plugins/installed` so install and enable states stay distinct. - if !plugin.enabled { - continue; - } - if !remote_installed_plugin_names.insert(plugin.name.clone()) { - return Err(PluginRemoteSyncError::DuplicateRemotePlugin { - plugin_name: plugin.name, - }); - } - } - - let mut config_edits = Vec::new(); - let mut installs = Vec::new(); - let mut uninstalls = Vec::new(); - let mut result = RemotePluginSyncResult::default(); - let remote_plugin_count = remote_installed_plugin_names.len(); - let local_plugin_count = local_plugins.len(); - if !missing_remote_plugins.is_empty() { - let sample_missing_plugins = missing_remote_plugins - .iter() - .take(10) - .cloned() - .collect::>(); - warn!( - marketplace = %marketplace_name, - missing_remote_plugin_count = missing_remote_plugins.len(), - missing_remote_plugin_examples = ?sample_missing_plugins, - "ignoring remote plugins missing from local marketplace during sync" - ); - } - - for ( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - ) in local_plugins - { - let plugin_key = plugin_id.as_key(); - let is_installed = installed_version.is_some(); - if !product_allowed { - continue; - } - if remote_installed_plugin_names.contains(&plugin_name) { - if !is_installed { - installs.push((source_path, plugin_id.clone(), cache_plugin_version.clone())); - } - if !is_installed { - result.installed_plugin_ids.push(plugin_key.clone()); - } - - if current_enabled != Some(true) { - result.enabled_plugin_ids.push(plugin_key.clone()); - config_edits.push(PluginConfigEdit::SetEnabled { - plugin_key, - enabled: true, - }); - } - } else if !additive_only { - if is_installed { - uninstalls.push(plugin_id); - } - if is_installed || current_enabled.is_some() { - result.uninstalled_plugin_ids.push(plugin_key.clone()); - } - if current_enabled.is_some() { - config_edits.push(PluginConfigEdit::Clear { plugin_key }); - } - } - } - - let store = self.store.clone(); - let store_result = tokio::task::spawn_blocking(move || { - for (source_path, plugin_id, plugin_version) in installs { - store.install_with_version(source_path, plugin_id, plugin_version)?; - } - for plugin_id in uninstalls { - store.uninstall(&plugin_id)?; - } - Ok::<(), PluginStoreError>(()) - }) - .await - .map_err(PluginRemoteSyncError::join)?; - if let Err(err) = store_result { - self.clear_cache(); - return Err(err.into()); - } - - let config_result = if config_edits.is_empty() { - Ok(()) - } else { - apply_user_plugin_config_edits(&self.codex_home, config_edits).await - }; - self.clear_cache(); - config_result.map_err(anyhow::Error::from)?; - - info!( - marketplace = %marketplace_name, - remote_plugin_count, - local_plugin_count, - installed_plugin_ids = ?result.installed_plugin_ids, - enabled_plugin_ids = ?result.enabled_plugin_ids, - disabled_plugin_ids = ?result.disabled_plugin_ids, - uninstalled_plugin_ids = ?result.uninstalled_plugin_ids, - "completed remote plugin sync" - ); - - Ok(result) - } - pub fn list_marketplaces_for_config( &self, config: &PluginsConfigInput, @@ -1619,13 +1293,6 @@ impl PluginsManager { warn!("failed to start configured marketplace auto-upgrade task: {err}"); } } - start_startup_remote_plugin_sync_once( - Arc::clone(self), - self.codex_home.clone(), - config.clone(), - auth_manager.clone(), - ); - let config_for_remote_sync = config.clone(); let manager = Arc::clone(self); let auth_manager_for_remote_sync = auth_manager.clone(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index f036ed40a5..b1be37655b 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2416,25 +2416,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { - let tmp = tempfile::tempdir().unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = false -"#, - ); - - let config = load_config(tmp.path(), tmp.path()).await; - let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false) - .await - .unwrap(); - - assert_eq!(outcome, RemotePluginSyncResult::default()); -} - #[tokio::test] async fn list_marketplaces_includes_curated_repo_marketplace() { let tmp = tempfile::tempdir().unwrap(); @@ -2925,431 +2906,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_reconciles_cache_and_config() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec![ - "gmail@openai-curated".to_string(), - "calendar@openai-curated".to_string(), - ], - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); - - let synced_config = load_config(tmp.path(), tmp.path()).await; - let curated_marketplace = manager - .list_marketplaces_for_config(&synced_config, &[]) - .unwrap() - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - .unwrap(); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![ - ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), false, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ true, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/gmail/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/calendar/local") - .is_dir() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#)); - assert!(config.contains("enabled = true")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: Vec::new(), - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], - } - ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/linear") - .exists() - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let err = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) - if message.contains("plugin source path is not a directory") - )); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains("enabled = false")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); - fs::write( - curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-first" - } - }, - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-second" - } - } - ] -}"#, - ) - .unwrap(); - write_plugin(&curated_root, "plugins/gmail-first", "gmail"); - write_plugin(&curated_root, "plugins/gmail-second", "gmail"); - fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); - fs::write( - curated_root.join("plugins/gmail-second/marker.txt"), - "second", - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], - enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - assert_eq!( - fs::read_to_string(tmp.path().join(format!( - "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt" - ))) - .unwrap(), - "first" - ); -} - #[tokio::test] async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs index dcf9f79eb8..137c33753b 100644 --- a/codex-rs/core-plugins/src/remote_legacy.rs +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -6,19 +6,9 @@ use serde::Deserialize; use std::time::Duration; use url::Url; -const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; -const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct RemotePluginStatusSummary { - pub name: String, - #[serde(default = "default_remote_marketplace_name")] - pub marketplace_name: String, - pub enabled: bool, -} - #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] struct RemotePluginMutationResponse { @@ -83,32 +73,21 @@ pub enum RemotePluginMutationError { #[derive(Debug, thiserror::Error)] pub enum RemotePluginFetchError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] + #[error("failed to send remote featured plugin request to {url}: {source}")] Request { url: String, #[source] source: reqwest::Error, }, - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + #[error("remote featured plugin request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: reqwest::StatusCode, body: String, }, - #[error("failed to parse remote plugin sync response from {url}: {source}")] + #[error("failed to parse remote featured plugin response from {url}: {source}")] Decode { url: String, #[source] @@ -116,44 +95,6 @@ pub enum RemotePluginFetchError { }, } -pub async fn fetch_remote_plugin_status( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, -) -> Result, RemotePluginFetchError> { - let Some(auth) = auth else { - return Err(RemotePluginFetchError::AuthRequired); - }; - if !auth.uses_codex_backend() { - return Err(RemotePluginFetchError::UnsupportedAuthMode); - } - - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/list"); - let client = build_reqwest_client(); - let request = client - .get(&url) - .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) - .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); - - let response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); - } - - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, - }) -} - pub async fn fetch_remote_featured_plugin_ids( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -224,10 +165,6 @@ fn ensure_codex_backend_auth( Ok(auth) } -fn default_remote_marketplace_name() -> String { - DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() -} - async fn post_remote_plugin_mutation( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, diff --git a/codex-rs/core-plugins/src/startup_remote_sync.rs b/codex-rs/core-plugins/src/startup_remote_sync.rs deleted file mode 100644 index 90c0e119c0..0000000000 --- a/codex-rs/core-plugins/src/startup_remote_sync.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use crate::manager::PluginsConfigInput; -use crate::manager::PluginsManager; -use crate::startup_sync::has_local_curated_plugins_snapshot; -use codex_login::AuthManager; -use tracing::info; -use tracing::warn; - -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; -const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10); - -pub(crate) fn start_startup_remote_plugin_sync_once( - manager: Arc, - codex_home: PathBuf, - config: PluginsConfigInput, - auth_manager: Arc, -) { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path()); - if marker_path.is_file() { - return; - } - - tokio::spawn(async move { - if marker_path.is_file() { - return; - } - - if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await { - warn!( - codex_home = %codex_home.display(), - "skipping startup remote plugin sync because curated marketplace is not ready" - ); - return; - } - - let auth = auth_manager.auth().await; - match manager - .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true) - .await - { - Ok(sync_result) => { - info!( - installed_plugin_ids = ?sync_result.installed_plugin_ids, - enabled_plugin_ids = ?sync_result.enabled_plugin_ids, - disabled_plugin_ids = ?sync_result.disabled_plugin_ids, - uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, - "completed startup remote plugin sync" - ); - if let Err(err) = - write_startup_remote_plugin_sync_marker(codex_home.as_path()).await - { - warn!( - error = %err, - path = %marker_path.display(), - "failed to persist startup remote plugin sync marker" - ); - } - } - Err(err) => { - warn!( - error = %err, - "startup remote plugin sync failed; will retry on next app-server start" - ); - } - } - }); -} - -fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf { - codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE) -} - -async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool { - let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT; - loop { - if has_local_curated_plugins_snapshot(codex_home) { - return true; - } - if tokio::time::Instant::now() >= deadline { - return false; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home); - if let Some(parent) = marker_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(marker_path, b"ok\n").await -} - -#[cfg(test)] -#[path = "startup_remote_sync_tests.rs"] -mod tests; diff --git a/codex-rs/core-plugins/src/startup_remote_sync_tests.rs b/codex-rs/core-plugins/src/startup_remote_sync_tests.rs deleted file mode 100644 index bdbc5e1a7a..0000000000 --- a/codex-rs/core-plugins/src/startup_remote_sync_tests.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::*; -use crate::PluginsManager; -use crate::startup_sync::curated_plugins_repo_path; -use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; -use crate::test_support::load_plugins_config; -use crate::test_support::write_curated_plugin_sha; -use crate::test_support::write_file; -use crate::test_support::write_openai_curated_marketplace; -use codex_config::CONFIG_TOML_FILE; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use pretty_assertions::assert_eq; -use std::sync::Arc; -use std::time::Duration; -use tempfile::tempdir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; - -#[tokio::test] -async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { - let tmp = tempdir().expect("tempdir"); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path()); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_plugins_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - start_startup_remote_plugin_sync_once( - Arc::clone(&manager), - tmp.path().to_path_buf(), - config, - auth_manager, - ); - - let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if marker_path.is_file() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("marker should be written"); - - assert!( - tmp.path() - .join(format!( - "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}" - )) - .is_dir() - ); - let config = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist"); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - - let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable"); - assert_eq!(marker_contents, "ok\n"); -} diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 07f1fd4f26..2da64f9674 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -88,10 +88,6 @@ pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str } } -pub(crate) fn write_curated_plugin_sha(codex_home: &Path) { - write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); -} - pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) { write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); } From ffe90cb5c37d4cf6ccfa9de0126a5ff61cb959e5 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Fri, 5 Jun 2026 17:23:40 -0700 Subject: [PATCH 091/141] [codex] Use standalone tools for Responses Lite (#26490) ## Summary Responses Lite does not execute hosted Responses tools, so models using it must route web search and image generation through Codex-owned executors & standalone Response's API endpoints. This PR is stacked on #26487. ## Validation - `cargo test -p codex-core responses_lite_ --lib` - `cargo test -p codex-core standalone_executors_remain_hidden_without_flags_or_responses_lite --lib` - `cargo test -p codex-core hosted_tools_follow_provider_auth_model_and_config_gates --lib` - `cargo test -p codex-web-search-extension -p codex-image-generation-extension` - `cargo test -p codex-app-server --test all standalone_` - `cargo fmt --all -- --check` --- codex-rs/Cargo.lock | 4 +- codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/tools/spec_plan.rs | 64 ++++--- codex-rs/core/tests/common/test_codex.rs | 31 +++- codex-rs/core/tests/suite/client.rs | 16 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/responses_lite.rs | 167 ++++++++++++++++++ codex-rs/ext/image-generation/Cargo.toml | 1 - .../ext/image-generation/src/extension.rs | 11 +- codex-rs/ext/web-search/Cargo.toml | 1 - codex-rs/ext/web-search/src/extension.rs | 11 +- 11 files changed, 259 insertions(+), 50 deletions(-) create mode 100644 codex-rs/core/tests/suite/responses_lite.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a61df434f1..d67e418298 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2549,6 +2549,7 @@ dependencies = [ "codex-feedback", "codex-git-utils", "codex-hooks", + "codex-image-generation-extension", "codex-install-context", "codex-login", "codex-mcp", @@ -2584,6 +2585,7 @@ dependencies = [ "codex-utils-pty", "codex-utils-stream-parser", "codex-utils-string", + "codex-web-search-extension", "codex-windows-sandbox", "core_test_support", "csv", @@ -3048,7 +3050,6 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", - "codex-features", "codex-login", "codex-model-provider", "codex-model-provider-info", @@ -4208,7 +4209,6 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", - "codex-features", "codex-login", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 97e104464f..352e8051e4 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -133,9 +133,11 @@ codex-shell-escalation = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +codex-image-generation-extension = { workspace = true } codex-otel = { workspace = true } codex-test-binary-support = { workspace = true } codex-utils-cargo-bin = { workspace = true } +codex-web-search-extension = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } insta = { workspace = true } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 514432c880..b1a01cd7aa 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -56,6 +56,7 @@ use crate::tools::router::ToolRouterParams; use codex_features::Feature; use codex_login::AuthManager; use codex_mcp::ToolInfo; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -246,22 +247,31 @@ fn spec_for_model_request( fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { let turn_context = context.turn_context; + // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. + if turn_context.model_info.use_responses_lite { + return Vec::new(); + } + let mut specs = Vec::new(); - let provider_capabilities = turn_context.provider.capabilities(); - let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors) - && provider_capabilities.web_search) + let standalone_web_search_available = standalone_web_search_enabled(turn_context) + && context + .extension_tool_executors + .iter() + .any(|executor| executor.tool_name() == ToolName::namespaced("web", "run")); + // `Some(Cached/Live/Disabled)` are the options for mode when standalone search is unavailable + // and the provider supports hosted search. `None` prevents emitting a hosted search tool. + let web_search_mode = (!standalone_web_search_available + && turn_context.provider.capabilities().web_search) .then_some(turn_context.config.web_search_mode.value()); - let web_search_config = if provider_capabilities.web_search { - turn_context.config.web_search_config.as_ref() - } else { - None - }; - if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { + let web_search_config = web_search_mode + .as_ref() + .and(turn_context.config.web_search_config.as_ref()); + if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions { web_search_mode, web_search_config, web_search_tool_type: turn_context.model_info.web_search_tool_type, }) { - specs.push(web_search_tool); + specs.push(hosted_web_search_tool); } // TODO: Remove hosted image generation once the standalone extension is ready. if image_generation_tool_enabled(turn_context) @@ -336,9 +346,15 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { } fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { - image_generation_runtime_enabled(turn_context) - && turn_context.features.get().enabled(Feature::ImageGenExt) - && namespace_tools_enabled(turn_context) + if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) { + return false; + } + + if turn_context.model_info.use_responses_lite { + return true; + } + + turn_context.features.get().enabled(Feature::ImageGenExt) } fn standalone_image_generation_available( @@ -554,13 +570,13 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann } } -fn standalone_web_run_available( - extension_tools: &[Arc>], -) -> bool { - let web_run = ToolName::namespaced("web", "run"); - extension_tools - .iter() - .any(|executor| executor.tool_name() == web_run) +fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { + namespace_tools_enabled(turn_context) + && (turn_context.model_info.use_responses_lite + || turn_context + .features + .get() + .enabled(Feature::StandaloneWebSearch)) } fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { @@ -883,8 +899,16 @@ fn append_extension_tool_executors( reserved_tool_names.insert(ToolName::plain(TOOL_SEARCH_TOOL_NAME)); } + let standalone_web_search_enabled = standalone_web_search_enabled(turn_context); + let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled; + for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); + if tool_name == ToolName::namespaced("web", "run") + && (!standalone_web_search_enabled || !web_search_mode_on) + { + continue; + } if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) && !standalone_image_generation_model_visible(turn_context) { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 496bf03f57..1e43135900 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,12 +23,14 @@ use codex_core::thread_store_from_config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; +use codex_extension_api::ExtensionRegistry; use codex_extension_api::empty_extension_registry; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -216,6 +218,7 @@ pub struct TestCodexBuilder { cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, + extensions: Arc>, } impl TestCodexBuilder { @@ -239,6 +242,26 @@ impl TestCodexBuilder { }) } + pub fn with_model_info_override(self, model: &str, override_model_info: T) -> Self + where + T: FnOnce(&mut ModelInfo) + Send + 'static, + { + let model = model.to_string(); + self.with_config(move |config| { + let model_catalog = config.model_catalog.get_or_insert_with(|| { + bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")) + }); + let model_info = model_catalog + .models + .iter_mut() + .find(|model_info| model_info.slug == model) + .unwrap_or_else(|| panic!("{model} should exist in the configured model catalog")); + override_model_info(model_info); + config.model = Some(model); + }) + } + pub fn with_pre_build_hook(mut self, hook: F) -> Self where F: FnOnce(&Path) + Send + 'static, @@ -280,6 +303,11 @@ impl TestCodexBuilder { self } + pub fn with_extensions(mut self, extensions: Arc>) -> Self { + self.extensions = extensions; + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -473,7 +501,7 @@ impl TestCodexBuilder { codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, Arc::clone(&environment_manager), - empty_extension_registry(), + Arc::clone(&self.extensions), /*analytics_events_client*/ None, thread_store, state_db.clone(), @@ -1041,6 +1069,7 @@ pub fn test_codex() -> TestCodexBuilder { cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, + extensions: empty_extension_registry(), } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7c9e07d230..2fcb73e25a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1894,20 +1894,10 @@ async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls( ) .await; - let mut model_catalog = bundled_models_response() - .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - let model = model_catalog - .models - .iter_mut() - .find(|model| model.slug == "gpt-5.4") - .expect("gpt-5.4 exists in bundled models.json"); - model.use_responses_lite = true; - model.supports_parallel_tool_calls = true; - let TestCodex { codex, .. } = test_codex() - .with_model("gpt-5.4") - .with_config(move |config| { - config.model_catalog = Some(model_catalog); + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.supports_parallel_tool_calls = true; }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1e4a5f9501..ce853e810f 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -91,6 +91,7 @@ mod request_permissions_tool; mod request_plugin_install; mod request_user_input; mod responses_api_proxy_headers; +mod responses_lite; mod resume; mod resume_warning; mod review; diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs new file mode 100644 index 0000000000..c9cbcbffdd --- /dev/null +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -0,0 +1,167 @@ +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use codex_core::config::Config; +use codex_extension_api::ExtensionRegistry; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::Feature; +use codex_image_generation_extension::install as install_image_generation_extension; +use codex_login::CodexAuth; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::openai_models::InputModality; +use codex_web_search_extension::install as install_web_search_extension; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use serde_json::Value; + +fn responses_extensions(auth: &CodexAuth) -> Arc> { + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); + let mut extension_builder = ExtensionRegistryBuilder::::new(); + install_web_search_extension(&mut extension_builder, Arc::clone(&auth_manager)); + install_image_generation_extension(&mut extension_builder, auth_manager); + Arc::new(extension_builder.build()) +} + +fn configure_responses_tools(config: &mut Config) { + assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok()); + assert!( + config + .features + .disable(Feature::StandaloneWebSearch) + .is_ok() + ); + assert!(config.features.enable(Feature::ImageGeneration).is_ok()); + assert!(config.features.disable(Feature::ImageGenExt).is_ok()); +} + +fn configure_image_capable_model(model_info: &mut codex_protocol::openai_models::ModelInfo) { + model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; +} + +fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { + tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let extensions = responses_extensions(&auth); + + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(extensions) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + configure_image_capable_model(model_info); + }) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Use standalone tools").await?; + + let request = response_mock.single_request(); + request + .tool_by_name("web", "run") + .context("Responses Lite should expose standalone web search")?; + request + .tool_by_name("image_gen", "imagegen") + .context("Responses Lite should expose standalone image generation")?; + + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(!has_hosted_tool(tools, "web_search")); + assert!(!has_hosted_tool(tools, "image_generation")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + configure_image_capable_model(model_info); + }) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Do not use hosted tools").await?; + + let body = response_mock.single_request().body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(!has_hosted_tool(tools, "web_search")); + assert!(!has_hosted_tool(tools, "image_generation")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let extensions = responses_extensions(&auth); + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(extensions) + .with_model_info_override("gpt-5.4", configure_image_capable_model) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Use hosted tools").await?; + + let request = response_mock.single_request(); + assert!(request.tool_by_name("web", "run").is_none()); + assert!(request.tool_by_name("image_gen", "imagegen").is_none()); + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(has_hosted_tool(tools, "web_search")); + assert!(has_hosted_tool(tools, "image_generation")); + + Ok(()) +} diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index 3f22bd3fff..c6b87e1d4f 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -17,7 +17,6 @@ async-trait = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } -codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index 6f6a7c7b1b..2c68f614f5 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -9,7 +9,6 @@ use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; -use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; @@ -25,7 +24,7 @@ struct ImageGenerationExtension { #[derive(Clone)] struct ImageGenerationExtensionConfig { - enabled: bool, + available: bool, provider: ModelProviderInfo, codex_home: AbsolutePathBuf, } @@ -34,8 +33,8 @@ impl From<&Config> for ImageGenerationExtensionConfig { /// Resolves whether standalone image generation should be available for a thread. fn from(config: &Config) -> Self { Self { - enabled: config.features.enabled(Feature::ImageGenExt) - && config.model_provider.is_openai(), + // Core selects this executor per turn using the feature flag or model metadata. + available: config.model_provider.is_openai(), provider: config.model_provider.clone(), codex_home: config.codex_home.clone(), } @@ -75,7 +74,7 @@ impl ToolContributor for ImageGenerationExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() { + if !config.available || !self.auth_manager.current_auth_uses_codex_backend() { return Vec::new(); } @@ -90,7 +89,7 @@ impl ToolContributor for ImageGenerationExtension { } } -/// Installs the feature-gated standalone image-generation extension contributors. +/// Installs the standalone image-generation extension contributors. pub fn install(registry: &mut ExtensionRegistryBuilder, auth_manager: Arc) { let extension = Arc::new(ImageGenerationExtension { auth_manager }); registry.thread_lifecycle_contributor(extension.clone()); diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml index 0faabf21a8..954ac3dac9 100644 --- a/codex-rs/ext/web-search/Cargo.toml +++ b/codex-rs/ext/web-search/Cargo.toml @@ -17,7 +17,6 @@ async-trait = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } -codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 30fc48961e..d081d4fb29 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -13,7 +13,6 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolContributor; -use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; @@ -29,7 +28,7 @@ struct WebSearchExtension { #[derive(Clone)] struct WebSearchExtensionConfig { - enabled: bool, + available: bool, provider: ModelProviderInfo, settings: SearchSettings, } @@ -38,8 +37,8 @@ impl From<&Config> for WebSearchExtensionConfig { fn from(config: &Config) -> Self { let web_search_mode = config.web_search_mode.value(); Self { - enabled: config.features.enabled(Feature::StandaloneWebSearch) - && config.model_provider.is_openai() + // Core selects this executor per turn using the feature flag or model metadata. + available: config.model_provider.is_openai() && web_search_mode != WebSearchMode::Disabled, provider: config.model_provider.clone(), settings: search_settings(config, web_search_mode), @@ -111,7 +110,7 @@ impl ToolContributor for WebSearchExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.enabled { + if !config.available { return Vec::new(); } @@ -160,7 +159,7 @@ mod tests { let session_store = ExtensionData::new("session"); let thread_store = ExtensionData::new("11111111-1111-4111-8111-111111111111"); thread_store.insert(WebSearchExtensionConfig { - enabled: true, + available: true, provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None), settings: Default::default(), }); From 61a913d9c83928335b9503ad530caa9851bf5955 Mon Sep 17 00:00:00 2001 From: vie-oai Date: Fri, 5 Jun 2026 17:23:45 -0700 Subject: [PATCH 092/141] [codex] Gate terminal visualization instructions in TUI (#26013) ## Summary - add `Feature::TerminalVisualizationInstructions` as `UnderDevelopment`, disabled by default - keep terminal visualization instructions inside the TUI package - append them to existing developer instructions for TUI start, resume, and fork flows only when enabled - intentionally do not apply them to `codex exec` ## Rollout Control behavior is unchanged. TUI dogfooders can enable `terminal_visualization_instructions`; no default user receives the new terminal-specific instructions. The shared visualization-selection rule is supplied separately through the `codex_proxy_model_3` Statsig layer for every target Codex model slug in the gated cohort. This TUI feature determines how to render an appropriate visualization on the terminal surface; the model-layer treatment determines when to use one. ## Validation - `cargo test -p codex-tui terminal_visualization_instructions_are_gated_for_all_tui_thread_flows --lib` - `cargo test -p codex-features --lib` - `cargo fmt --all -- --check` - `git diff --check` - GPT-5.4 and GPT-5.5 real prompt-pipeline smoke tests: both visualized the positive mapping case, abstained on the negative route case, and passed exact prompt-stack verification on CLI and App - refreshed onto current `main` with a clean merge and reran the focused validation The full 53-probe all-model treatment comparison and requested production coding evals remain rollout gates before broadening beyond the initial employee cohort. This PR remains open for normal human review. --- codex-rs/core/config.schema.json | 6 ++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/tui/src/app_server_session.rs | 86 ++++++++++++++++++- codex-rs/tui/src/lib.rs | 1 + .../terminal_visualization_instructions.rs | 29 +++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/terminal_visualization_instructions.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f5a97b142a..b30b868bc7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -614,6 +614,9 @@ "terminal_resize_reflow": { "type": "boolean" }, + "terminal_visualization_instructions": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -4737,6 +4740,9 @@ "terminal_resize_reflow": { "type": "boolean" }, + "terminal_visualization_instructions": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3ba973b804..8ee05aed21 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -99,6 +99,8 @@ pub enum Feature { UnifiedExecZshFork, /// Reflow transcript scrollback when the terminal is resized. TerminalResizeReflow, + /// Add terminal-specific visualization guidance to TUI developer instructions. + TerminalVisualizationInstructions, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -1118,6 +1120,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::TerminalVisualizationInstructions, + key: "terminal_visualization_instructions", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::GuardianApproval, key: "guardian_approval", diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a04faa5261..f5836f4c25 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -11,6 +11,7 @@ use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; +use crate::terminal_visualization_instructions::with_terminal_visualization_instructions; use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; @@ -1412,6 +1413,9 @@ fn thread_start_params_from_config( ephemeral: Some(config.ephemeral), session_start_source, thread_source: Some(ThreadSource::User), + developer_instructions: with_terminal_visualization_instructions( + config, /*control_instructions*/ None, + ), ..ThreadStartParams::default() } } @@ -1444,6 +1448,9 @@ fn thread_resume_params_from_config( sandbox, permissions, config: config_request_overrides_from_config(&config), + developer_instructions: with_terminal_visualization_instructions( + &config, /*control_instructions*/ None, + ), ..ThreadResumeParams::default() } } @@ -1477,7 +1484,10 @@ fn thread_fork_params_from_config( permissions, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), - developer_instructions: config.developer_instructions.clone(), + developer_instructions: with_terminal_visualization_instructions( + &config, + config.developer_instructions.clone(), + ), ephemeral: config.ephemeral, thread_source: Some(ThreadSource::User), ..ThreadForkParams::default() @@ -1748,6 +1758,7 @@ mod tests { use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; + use codex_features::Feature; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; @@ -2235,6 +2246,79 @@ mod tests { ); } + #[tokio::test] + async fn terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config.developer_instructions = Some("Developer override.".to_string()); + let thread_id = ThreadId::new(); + + let control_start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let control_resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let control_fork = thread_fork_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + + assert_eq!(control_start.developer_instructions, None); + assert_eq!(control_resume.developer_instructions, None); + assert_eq!( + control_fork.developer_instructions.as_deref(), + Some("Developer override.") + ); + + let _ = config + .features + .enable(Feature::TerminalVisualizationInstructions); + let treatment_start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let treatment_resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let treatment_fork = thread_fork_params_from_config( + config, + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let expected = format!( + "Developer override.\n\n{}", + crate::terminal_visualization_instructions::TERMINAL_VISUALIZATION_INSTRUCTIONS + ); + + assert_eq!( + treatment_start.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + treatment_resume.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + treatment_fork.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + } + #[tokio::test] async fn resume_response_restores_turns_from_thread_items() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5e83c07461..5e6b6cf81c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -188,6 +188,7 @@ mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; +mod terminal_visualization_instructions; mod text_formatting; mod theme_picker; mod token_usage; diff --git a/codex-rs/tui/src/terminal_visualization_instructions.rs b/codex-rs/tui/src/terminal_visualization_instructions.rs new file mode 100644 index 0000000000..eb59f2d157 --- /dev/null +++ b/codex-rs/tui/src/terminal_visualization_instructions.rs @@ -0,0 +1,29 @@ +use crate::legacy_core::config::Config; +use codex_features::Feature; + +pub(crate) const TERMINAL_VISUALIZATION_INSTRUCTIONS: &str = "\ +- This surface is a terminal. When the formatting rules require a visual, include one in the final answer using compact ASCII diagrams, trees, timelines, or tables. +- Use tables for exact mappings or comparisons rather than collapsing known mappings into prose. +- Use trees for hierarchy or one-to-many relationships, and diagrams or timelines for sequence, change, or state transferred between records across event order. +- Use only ASCII characters in visuals."; + +pub(crate) fn with_terminal_visualization_instructions( + config: &Config, + control_instructions: Option, +) -> Option { + if !config + .features + .enabled(Feature::TerminalVisualizationInstructions) + { + return control_instructions; + } + + let existing_instructions = + control_instructions.or_else(|| config.developer_instructions.clone()); + Some(match existing_instructions.as_deref() { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{TERMINAL_VISUALIZATION_INSTRUCTIONS}") + } + _ => TERMINAL_VISUALIZATION_INSTRUCTIONS.to_string(), + }) +} From df7818c7d138ad081f5bd1fa584646c116da7022 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 5 Jun 2026 17:36:18 -0700 Subject: [PATCH 093/141] [codex-rs] support v2 personal access tokens (#25731) ## Summary - add v2 personal access token support for `codex login --with-access-token` and `CODEX_ACCESS_TOKEN` - classify opaque `at-` tokens separately from legacy Agent Identity JWTs - hydrate required ChatGPT account metadata through AuthAPI `/v1/user-auth-credential/whoami` - use PATs directly as bearer tokens while preserving existing ChatGPT account surfaces - expose PAT-backed auth as the explicit `personalAccessToken` app-server auth mode ## Implementation PAT auth is intentionally small and stateless. Loading a PAT performs one AuthAPI metadata request, stores the hydrated metadata in the in-memory auth object, and redacts the secret from debug output. Legacy Agent Identity JWT handling remains unchanged. The shared access-token classifier lives in a private neutral module because it dispatches between both credential types. PAT hydration fails closed when AuthAPI omits any required metadata, including email. Hydrated metadata is intentionally not persisted: startup performs a live `whoami` preflight so revoked tokens or changed account metadata are not accepted from a stale cache. ## Workspace restriction scope This change intentionally does **not** apply `forced_chatgpt_workspace_id` to PAT authentication. The setting is a client-side config guardrail, not an authorization boundary, and PAT does not currently require workspace-ID parity. The PAT login and `CODEX_ACCESS_TOKEN` paths therefore validate through AuthAPI without threading workspace-restriction state through access-token loading. Existing workspace checks for non-PAT auth remain on their established paths. ## App-server compatibility The public app-server `AuthMode` is shared across v1 and v2, and PAT-backed auth reports `personalAccessToken` through both APIs. Following human review, this intentionally removes the temporary v1 compatibility mapping that reported PATs as `chatgpt`; the deprecated v1 API is kept in parity with v2 rather than maintaining a separate closed enum. Clients with exhaustive auth-mode handling in either API version must add the new case and should generally treat it as ChatGPT-backed unless they need PAT-specific behavior. The v1 auth-status response still omits the raw PAT when `includeToken` is requested because that response cannot carry the account metadata needed to reuse the credential safely. Persisted PAT auth also omits the new enum value so older Codex builds can deserialize `auth.json` and infer PAT auth from the credential field after a rollback. ## Validation Latest review-fix validation: - `CARGO_INCREMENTAL=0 just test -p codex-login` (126 passed) - `CARGO_INCREMENTAL=0 just test -p codex-cli` (263 passed) - `CARGO_INCREMENTAL=0 just test -p codex-cli stored_auth_validation_handles_personal_access_token` - `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol` (226 passed) - `CARGO_INCREMENTAL=0 just test -p codex-models-manager refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth` - `CARGO_INCREMENTAL=0 just test -p codex-tui existing_non_oauth_chatgpt_login_counts_as_signed_in` - `CARGO_INCREMENTAL=0 just fix -p codex-login -p codex-app-server-protocol -p codex-models-manager -p codex-tui -p codex-cli` - `just fmt` - `git diff --check` The broader `codex-tui` suite previously compiled and ran 2,834 tests. Three unrelated environment-sensitive guardian/IDE-socket tests failed after retries; the PAT-relevant TUI coverage passed. --- .../schema/json/ServerNotification.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../json/v2/AccountUpdatedNotification.json | 7 + .../schema/typescript/AuthMode.ts | 2 +- .../src/protocol/common.rs | 15 ++ .../src/transport/remote_control/tests.rs | 1 + .../src/transport/remote_control/websocket.rs | 1 + codex-rs/app-server/README.md | 3 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/account_processor.rs | 44 ++--- .../app-server/tests/common/auth_fixtures.rs | 1 + codex-rs/app-server/tests/suite/auth.rs | 59 ++++++ .../app-server/tests/suite/v2/app_list.rs | 1 + codex-rs/chatgpt/src/workspace_settings.rs | 12 +- codex-rs/cli/src/doctor.rs | 41 +++- codex-rs/cli/src/login.rs | 4 + codex-rs/core/src/client.rs | 7 +- codex-rs/core/tests/suite/cli_stream.rs | 125 ++++++++++++ codex-rs/login/src/auth/access_token.rs | 18 ++ codex-rs/login/src/auth/access_token_tests.rs | 13 ++ codex-rs/login/src/auth/auth_tests.rs | 184 ++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 160 ++++++++++++--- codex-rs/login/src/auth/mod.rs | 2 + .../login/src/auth/personal_access_token.rs | 112 +++++++++++ .../src/auth/personal_access_token_tests.rs | 71 +++++++ codex-rs/login/src/auth/storage.rs | 3 + codex-rs/login/src/auth/storage_tests.rs | 28 +++ codex-rs/login/src/server.rs | 16 ++ codex-rs/login/tests/suite/auth_refresh.rs | 25 +++ codex-rs/login/tests/suite/logout.rs | 1 + codex-rs/model-provider-info/src/lib.rs | 7 +- .../src/model_provider_info_tests.rs | 9 + codex-rs/model-provider/src/auth.rs | 15 +- codex-rs/model-provider/src/provider.rs | 18 +- codex-rs/models-manager/src/manager.rs | 7 +- codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/otel/src/lib.rs | 3 +- codex-rs/tui/src/app/app_server_events.rs | 7 +- codex-rs/tui/src/app_server_session.rs | 3 +- codex-rs/tui/src/local_chatgpt_auth.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 27 +-- 42 files changed, 980 insertions(+), 97 deletions(-) create mode 100644 codex-rs/login/src/auth/access_token.rs create mode 100644 codex-rs/login/src/auth/access_token_tests.rs create mode 100644 codex-rs/login/src/auth/personal_access_token.rs create mode 100644 codex-rs/login/src/auth/personal_access_token_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 010ec33e08..96b24f9324 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -525,6 +525,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a5578e0072..15ed98e356 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6677,6 +6677,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 36b95b4f64..ebf8796cd6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -999,6 +999,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index e7546f5570..013592c9ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -31,6 +31,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 210e54c4a5..1cb6ccb6b3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1c996d7cb0..d3c8eae22b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -36,6 +36,21 @@ pub enum AuthMode { #[ts(rename = "agentIdentity")] #[strum(serialize = "agentIdentity")] AgentIdentity, + /// Programmatic Codex auth backed by a personal access token. + #[serde(rename = "personalAccessToken")] + #[ts(rename = "personalAccessToken")] + #[strum(serialize = "personalAccessToken")] + PersonalAccessToken, +} + +impl AuthMode { + /// Returns whether this mode represents an authenticated human ChatGPT account. + pub fn has_chatgpt_account(self) -> bool { + match self { + Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, + Self::ApiKey | Self::AgentIdentity => false, + } + } } macro_rules! experimental_reason_expr { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 91c13515d3..69659e84a1 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -115,6 +115,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { }), last_refresh: Some(chrono::Utc::now()), agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 43fce8ed36..86b72849d3 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1769,6 +1769,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a12144238a..3e294d4cf3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1770,6 +1770,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. - **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. +- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`. ### API Overview @@ -1778,7 +1779,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. -- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 0266cb9d95..57205798b2 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -357,7 +357,6 @@ use codex_mcp::discover_supported_scopes; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; -use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 21160d6810..a4bbe42d61 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -776,24 +776,28 @@ impl AccountRequestProcessor { let permanent_refresh_failure = self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = - if matches!(auth, CodexAuth::AgentIdentity(_)) - || include_token && permanent_refresh_failure - { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } + let (reported_auth_method, token_opt) = if matches!( + auth, + CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) + ) || include_token + && permanent_refresh_failure + { + // This response cannot represent the metadata needed to reuse these + // credentials. + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) } - }; + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, @@ -825,11 +829,7 @@ impl AccountRequestProcessor { ); let account_state = match provider.account_state() { Ok(account_state) => account_state, - Err(ProviderAccountError::MissingChatgptAccountDetails) => { - return Err(invalid_request( - "email and plan type are required for chatgpt authentication", - )); - } + Err(err) => return Err(invalid_request(err.to_string())), }; let account = account_state.account.map(Account::from); diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 86f0fb456d..cf78e788de 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -164,6 +164,7 @@ pub fn write_chatgpt_auth( tokens: Some(tokens), last_refresh, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index b1ef99cb71..6e88866c7d 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -21,6 +21,7 @@ use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -160,6 +161,64 @@ async fn get_auth_status_with_api_key() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("Authorization", "Bearer at-test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }))) + .expect(1..) + .mount(&server) + .await; + + let authapi_base_url = server.uri(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEX_ACCESS_TOKEN", Some("at-test-token")), + ("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::PersonalAccessToken), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 2177106f8f..c9615d3a27 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -118,6 +118,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, )?; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs index a177215518..d5875adcf8 100644 --- a/codex-rs/chatgpt/src/workspace_settings.rs +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -3,7 +3,6 @@ use std::sync::RwLock; use std::time::Duration; use std::time::Instant; -use anyhow::Context; use codex_core::config::Config; use codex_login::CodexAuth; use serde::Deserialize; @@ -93,20 +92,17 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(true); } - let token_data = auth - .get_token_data() - .context("ChatGPT token data is not available")?; - if !token_data.id_token.is_workspace_account() { + if !auth.is_workspace_account() { return Ok(true); } - let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + let Some(account_id) = auth.get_account_id().filter(|id| !id.is_empty()) else { return Ok(true); }; let cache_key = WorkspaceSettingsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), - account_id: account_id.to_string(), + account_id: account_id.clone(), }; if let Some(cache) = cache && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) @@ -114,7 +110,7 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(enabled); } - let encoded_account_id = encode_path_segment(account_id); + let encoded_account_id = encode_path_segment(&account_id); let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( config, format!("/accounts/{encoded_account_id}/settings"), diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2e53d5d51f..dde6fad470 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1308,6 +1308,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -1317,6 +1318,8 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth } if auth.openai_api_key.is_some() { codex_app_server_protocol::AuthMode::ApiKey + } else if auth.personal_access_token.is_some() { + codex_app_server_protocol::AuthMode::PersonalAccessToken } else { codex_app_server_protocol::AuthMode::Chatgpt } @@ -1380,6 +1383,15 @@ fn stored_auth_issues( issues.push("agent identity auth is missing an agent identity token"); } } + codex_app_server_protocol::AuthMode::PersonalAccessToken => { + if auth + .personal_access_token + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("personal access token auth is missing a personal access token"); + } + } } issues } @@ -2408,6 +2420,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -2541,7 +2554,8 @@ fn provider_auth_reachability_mode_from_auth( Some( codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken, ) | None => ProviderAuthReachabilityMode::Chatgpt, } @@ -3401,6 +3415,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3418,6 +3433,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3429,6 +3445,28 @@ mod tests { ); } + #[test] + fn stored_auth_validation_handles_personal_access_token() { + let mut auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-test".to_string()), + }; + + assert_eq!(stored_auth_mode(&auth), "personal_access_token"); + assert!(stored_auth_issues(&auth, |_| false).is_empty()); + + auth.auth_mode = Some(codex_app_server_protocol::AuthMode::PersonalAccessToken); + auth.personal_access_token = None; + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["personal access token auth is missing a personal access token"] + ); + } + #[test] fn provider_reachability_mode_uses_api_key_auth() { let api_key_auth = AuthDotJson { @@ -3437,6 +3475,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 6fdc62fdb1..b3fc1bd544 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -391,6 +391,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using access token"); std::process::exit(0); } + AuthMode::PersonalAccessToken => { + eprintln!("Logged in using personal access token"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ee8ea3619f..682e84c7ff 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1950,9 +1950,10 @@ impl AuthRequestTelemetryContext { Self { auth_mode: auth_mode.map(|mode| match mode { AuthMode::ApiKey => "ApiKey", - AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { - "Chatgpt" - } + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken => "Chatgpt", }), auth_header_attached: auth_telemetry.attached, auth_header_name: auth_telemetry.name, diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 361096943e..fc9b7d2569 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,14 +1,27 @@ use assert_cmd::Command as AssertCommand; use codex_git_utils::collect_git_info; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::CODEX_API_KEY_ENV_VAR; use codex_protocol::protocol::GitInfo; use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test"; +const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test"; +const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; +const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle"; fn repo_root() -> std::path::PathBuf { #[expect(clippy::expect_used)] @@ -23,6 +36,118 @@ fn cli_sse_response() -> String { ]) } +async fn mount_personal_access_token_startup(server: &MockServer) { + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-pat", + "chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }))) + .expect(1..) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(CLOUD_CONFIG_BUNDLE_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(server) + .await; +} + +#[expect(clippy::unwrap_used)] +fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> AssertCommand { + let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); + let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); + cmd.arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(format!("openai_base_url=\"{}/api/codex\"", server.uri())) + .arg("-c") + .arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri())) + .arg("-C") + .arg(repo_root()) + .arg("hello?"); + cmd.env("CODEX_HOME", home.path()) + .env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN) + .env("CODEX_AUTHAPI_BASE_URL", server.uri()) + .env_remove(CODEX_API_KEY_ENV_VAR) + .env_remove("OPENAI_API_KEY"); + cmd +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_supports_personal_access_tokens() { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "codex-cli exec failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let request = resp_mock.single_request(); + assert_eq!(request.path(), "/api/codex/responses"); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer at-cli-test") + ); + assert_eq!( + request.header("chatgpt-account-id").as_deref(), + Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID) + ); + assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true")); + server.verify().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401() + { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + Mock::given(method("POST")) + .and(path("/api/codex/responses")) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .and(header( + "chatgpt-account-id", + PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + )) + .and(header("x-openai-fedramp", "true")) + .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) + .expect(1..) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!(!output.status.success()); + server.verify().await; +} + /// Tests streaming the Responses API through the CLI using a mock server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_mode_stream_cli() { diff --git a/codex-rs/login/src/auth/access_token.rs b/codex-rs/login/src/auth/access_token.rs new file mode 100644 index 0000000000..5859ab1c25 --- /dev/null +++ b/codex-rs/login/src/auth/access_token.rs @@ -0,0 +1,18 @@ +const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "at-"; + +pub(super) enum CodexAccessToken<'a> { + PersonalAccessToken(&'a str), + AgentIdentityJwt(&'a str), +} + +pub(super) fn classify_codex_access_token(access_token: &str) -> CodexAccessToken<'_> { + if access_token.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) { + CodexAccessToken::PersonalAccessToken(access_token) + } else { + CodexAccessToken::AgentIdentityJwt(access_token) + } +} + +#[cfg(test)] +#[path = "access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/access_token_tests.rs b/codex-rs/login/src/auth/access_token_tests.rs new file mode 100644 index 0000000000..d734d149d5 --- /dev/null +++ b/codex-rs/login/src/auth/access_token_tests.rs @@ -0,0 +1,13 @@ +use super::*; + +#[test] +fn classifies_personal_access_tokens_by_prefix() { + assert!(matches!( + classify_codex_access_token("at-example"), + CodexAccessToken::PersonalAccessToken("at-example") + )); + assert!(matches!( + classify_codex_access_token("header.payload.signature"), + CodexAccessToken::AgentIdentityJwt("header.payload.signature") + )); +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 63ab3e0c42..e7b5502ebc 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -19,6 +19,7 @@ use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -126,6 +127,84 @@ async fn login_with_access_token_writes_only_token() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_writes_only_personal_access_token() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-login-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + super::login_with_access_token( + dir.path(), + "at-login-test", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("personal access token login should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!( + auth, + AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-login-test".to_string()), + } + ); + assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap(); + assert!(persisted.get("auth_mode").is_none()); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_rejects_invalid_personal_access_token() { + let dir = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + + let err = super::login_with_access_token( + dir.path(), + "at-invalid-login", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect_err("invalid personal access token should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert!( + !get_auth_file(dir.path()).exists(), + "invalid personal access token should not write auth.json" + ); + server.verify().await; +} + #[tokio::test] async fn login_with_access_token_rejects_invalid_jwt() { let dir = tempdir().unwrap(); @@ -245,6 +324,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { }), last_refresh: Some(last_refresh), agent_identity: None, + personal_access_token: None, }, auth_dot_json ); @@ -286,6 +366,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); @@ -762,6 +843,98 @@ async fn load_auth_reads_access_token_from_env() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_reads_personal_access_token_from_env() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-env-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(2) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test"); + + for auth_credentials_store_mode in [ + AuthCredentialsStoreMode::File, + AuthCredentialsStoreMode::Ephemeral, + ] { + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + /*chatgpt_base_url*/ None, + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken); + assert_eq!( + auth.get_token() + .expect("personal access token should be exposed"), + "at-env-test" + ); + assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED)); + assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123")); + assert_eq!( + auth.get_account_email().as_deref(), + Some("user@example.com") + ); + assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business)); + assert!(auth.is_fedramp_account()); + } + assert!( + !get_auth_file(codex_home.path()).exists(), + "env auth should not write auth.json" + ); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn personal_access_token_does_not_offer_unauthorized_recovery() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = + EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery"); + let manager = Arc::new( + AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let recovery = manager.unauthorized_recovery(); + + assert!(!recovery.has_next()); + assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth"); + manager + .refresh_token_from_authority() + .await + .expect("personal access tokens do not use OAuth refresh"); + server.verify().await; +} + #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { @@ -938,6 +1111,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) @@ -1091,6 +1265,16 @@ fn test_jwks_body() -> serde_json::Value { }) } +fn personal_access_token_whoami(account_id: &str) -> serde_json::Value { + json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": account_id, + "chatgpt_plan_type": "business", + "chatgpt_account_is_fedramp": true, + }) +} + const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 22e2c9f3b9..9e255a99a3 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -24,9 +24,12 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; +use super::access_token::CodexAccessToken; +use super::access_token::classify_codex_access_token; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -53,11 +56,15 @@ pub enum CodexAuth { Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), AgentIdentity(AgentIdentityAuth), + PersonalAccessToken(PersonalAccessTokenAuth), } impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { - self.api_auth_mode() == other.api_auth_mode() + match (self, other) { + (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, + _ => self.api_auth_mode() == other.api_auth_mode(), + } } } @@ -220,6 +227,14 @@ impl CodexAuth { }; return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; } + if auth_mode == ApiAuthMode::PersonalAccessToken { + let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { + return Err(std::io::Error::other( + "personal access token auth is missing a personal access token.", + )); + }; + return Self::from_personal_access_token(personal_access_token).await; + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { @@ -237,6 +252,9 @@ impl CodexAuth { } ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), + ApiAuthMode::PersonalAccessToken => { + unreachable!("personal access token mode is handled above") + } } } @@ -266,11 +284,18 @@ impl CodexAuth { Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) } + pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + Ok(Self::PersonalAccessToken( + PersonalAccessTokenAuth::load(access_token).await?, + )) + } + pub fn auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, Self::AgentIdentity(_) => AuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, } } @@ -280,6 +305,7 @@ impl CodexAuth { Self::Chatgpt(_) => ApiAuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken, } } @@ -287,14 +313,21 @@ impl CodexAuth { self.auth_mode() == AuthMode::ApiKey } + pub fn is_personal_access_token_auth(&self) -> bool { + self.auth_mode() == AuthMode::PersonalAccessToken + } + pub fn is_chatgpt_auth(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + self.api_auth_mode().has_chatgpt_account() } pub fn uses_codex_backend(&self) -> bool { matches!( self, - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) ) } @@ -302,11 +335,18 @@ impl CodexAuth { matches!(self, Self::ChatgptAuthTokens(_)) } + fn supports_unauthorized_recovery(&self) -> bool { + matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + } + /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None, + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) => None, } } @@ -334,6 +374,7 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), } } @@ -341,6 +382,7 @@ impl CodexAuth { pub fn get_account_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()), _ => self.get_current_token_data().and_then(|t| t.account_id), } } @@ -349,6 +391,7 @@ impl CodexAuth { pub fn is_fedramp_account(&self) -> bool { match self { Self::AgentIdentity(auth) => auth.is_fedramp_account(), + Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self .get_current_token_data() .is_some_and(|t| t.id_token.is_fedramp_account()), @@ -359,6 +402,7 @@ impl CodexAuth { pub fn get_account_email(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.email().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), _ => self.get_current_token_data().and_then(|t| t.id_token.email), } } @@ -367,6 +411,7 @@ impl CodexAuth { pub fn get_chatgpt_user_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self .get_current_token_data() .and_then(|t| t.id_token.chatgpt_user_id), @@ -380,6 +425,9 @@ impl CodexAuth { if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } + if let Self::PersonalAccessToken(auth) = self { + return Some(auth.plan_type()); + } self.get_current_token_data().map(|t| { t.id_token @@ -399,7 +447,7 @@ impl CodexAuth { let state = match self { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, - Self::ApiKey(_) | Self::AgentIdentity(_) => return None, + Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None, }; #[expect(clippy::unwrap_used)] state.auth_dot_json.lock().unwrap().clone() @@ -423,6 +471,7 @@ impl CodexAuth { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let client = create_client(); @@ -539,6 +588,7 @@ pub fn login_with_api_key( tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -550,17 +600,35 @@ pub async fn login_with_access_token( auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, ) -> std::io::Result<()> { - let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) - .trim_end_matches('/') - .to_string(); - verified_agent_identity_record(access_token, &base_url).await?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(ApiAuthMode::AgentIdentity), - openai_api_key: None, - tokens: None, - last_refresh: None, - agent_identity: Some(access_token.to_string()), + let auth_dot_json = match classify_codex_access_token(access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + PersonalAccessTokenAuth::load(access_token).await?; + AuthDotJson { + // Infer PAT auth from the credential field so older Codex builds can still + // deserialize auth.json after a rollback. + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some(access_token.to_string()), + } + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + let base_url = chatgpt_base_url + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .trim_end_matches('/') + .to_string(); + verified_agent_identity_record(jwt, &base_url).await?; + AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(jwt.to_string()), + personal_access_token: None, + } + } }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -633,10 +701,12 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None, + | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some( + | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -657,7 +727,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()), CodexAuth::AgentIdentity(_) => auth.get_account_id(), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { let token_data = match auth.get_token_data() { @@ -758,17 +828,26 @@ async fn load_auth( return Ok(Some(auth)); } + if let Some(access_token) = read_codex_access_token_from_env() { + return match classify_codex_access_token(&access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + CodexAuth::from_personal_access_token(access_token) + .await + .map(Some) + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) + .await + .map(Some) + } + }; + } + // If the caller explicitly requested ephemeral auth, there is no persisted fallback. if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { return Ok(None); } - if let Some(agent_identity) = read_codex_access_token_from_env() { - return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) - .await - .map(Some); - } - // Fall back to the configured persistent store (file/keyring/auto) for managed auth. let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { @@ -963,6 +1042,7 @@ impl AuthDotJson { tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }) } @@ -983,6 +1063,9 @@ impl AuthDotJson { if let Some(mode) = self.auth_mode { return mode; } + if self.personal_access_token.is_some() { + return ApiAuthMode::PersonalAccessToken; + } if self.openai_api_key.is_some() { return ApiAuthMode::ApiKey; } @@ -1126,7 +1209,7 @@ impl UnauthorizedRecovery { .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return false; } @@ -1147,11 +1230,20 @@ impl UnauthorizedRecovery { }; } + if self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_personal_access_token_auth) + { + return "not_refreshable_auth"; + } + if !self .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return "not_chatgpt_auth"; } @@ -1497,6 +1589,7 @@ impl AuthManager { } _ => false, }, + (ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b, _ => false, }, _ => false, @@ -1690,7 +1783,7 @@ impl AuthManager { let auth_before_reload = self.auth_cached(); if auth_before_reload .as_ref() - .is_some_and(CodexAuth::is_api_key_auth) + .is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth()) { return Ok(()); } @@ -1756,7 +1849,9 @@ impl AuthManager { self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) .await } - CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()), + CodexAuth::ApiKey(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); @@ -1805,7 +1900,12 @@ impl AuthManager { pub fn current_auth_uses_codex_backend(&self) -> bool { matches!( self.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) } diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 167ccd3f75..9bf6c350f7 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,6 +1,8 @@ +mod access_token; mod agent_identity; pub mod default_client; pub mod error; +mod personal_access_token; mod storage; mod util; diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs new file mode 100644 index 0000000000..b99092f51f --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -0,0 +1,112 @@ +use codex_client::CodexHttpClient; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::auth::PlanType as InternalPlanType; +use serde::Deserialize; +use std::env; +use std::fmt; + +use crate::default_client::create_client; + +const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct PersonalAccessTokenMetadata { + email: String, + chatgpt_user_id: String, + chatgpt_account_id: String, + chatgpt_plan_type: String, + chatgpt_account_is_fedramp: bool, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PersonalAccessTokenAuth { + access_token: String, + metadata: PersonalAccessTokenMetadata, +} + +impl fmt::Debug for PersonalAccessTokenAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersonalAccessTokenAuth") + .field("access_token", &"") + .field("metadata", &self.metadata) + .finish() + } +} + +impl PersonalAccessTokenAuth { + pub(super) async fn load(access_token: &str) -> std::io::Result { + let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) + .ok() + .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) + .filter(|base_url| !base_url.is_empty()) + .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); + hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + } + + pub fn access_token(&self) -> &str { + &self.access_token + } + + pub fn account_id(&self) -> &str { + &self.metadata.chatgpt_account_id + } + + pub fn chatgpt_user_id(&self) -> &str { + &self.metadata.chatgpt_user_id + } + + pub fn email(&self) -> &str { + &self.metadata.email + } + + pub fn plan_type(&self) -> AccountPlanType { + InternalPlanType::from_raw_value(&self.metadata.chatgpt_plan_type).into() + } + + pub fn is_fedramp_account(&self) -> bool { + self.metadata.chatgpt_account_is_fedramp + } +} + +async fn hydrate_personal_access_token( + client: &CodexHttpClient, + authapi_base_url: &str, + access_token: &str, +) -> std::io::Result { + let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); + let response = client + .get(&endpoint) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to request personal access token metadata: {err}" + )) + })?; + if !response.status().is_success() { + return Err(std::io::Error::other(format!( + "personal access token metadata request failed with status {}", + response.status() + ))); + } + + let metadata = response + .json::() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to decode personal access token metadata: {err}" + )) + })?; + Ok(PersonalAccessTokenAuth { + access_token: access_token.to_string(), + metadata, + }) +} + +#[cfg(test)] +#[path = "personal_access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs new file mode 100644 index 0000000000..ac6ee12265 --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -0,0 +1,71 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn response(email: Option<&str>) -> serde_json::Value { + json!({ + "email": email, + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }) +} + +#[tokio::test] +async fn hydrate_sends_bearer_token_and_preserves_metadata() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", "Bearer at-example")) + .respond_with(ResponseTemplate::new(200).set_body_json(response(Some("user@example.com")))) + .expect(1) + .mount(&server) + .await; + + let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect("personal access token hydration should succeed"); + + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: "user@example.com".to_string(), + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } + ); + server.verify().await; +} + +#[tokio::test] +async fn hydrate_rejects_missing_email() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(response(/*email*/ None))) + .expect(1) + .mount(&server) + .await; + + let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect_err("personal access token hydration should reject missing email"); + + assert!( + err.to_string() + .contains("failed to decode personal access token metadata") + ); + server.verify().await; +} diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 3a1c8ae6aa..d0e606a8cc 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -45,6 +45,9 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_identity: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub personal_access_token: Option, } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index b5646ef53e..debe0e18d3 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -19,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage @@ -40,6 +41,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let file = get_auth_file(codex_home.path()); @@ -73,6 +75,27 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::PersonalAccessToken), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-example".to_string()), }; storage.save(&auth_dot_json)?; @@ -122,6 +145,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -146,6 +170,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth_dot_json)?; @@ -245,6 +270,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } @@ -270,6 +296,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; seed_keyring_with_auth( &mock_keyring, @@ -313,6 +340,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth)?; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b72bc946f2..5a1833b58c 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -822,6 +822,7 @@ pub(crate) async fn persist_tokens_async( tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(&codex_home, &auth, auth_credentials_store_mode)?; Ok::<_, io::Error>((previous_auth, auth)) @@ -940,6 +941,20 @@ pub(crate) fn ensure_workspace_allowed( return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); }; + ensure_workspace_account_allowed(Some(expected), actual) +} + +/// Validates an already known ChatGPT account ID against an optional workspace restriction. +/// +/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token. +pub(crate) fn ensure_workspace_account_allowed( + expected: Option<&[String]>, + actual: &str, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + if expected.iter().any(|workspace_id| workspace_id == actual) { Ok(()) } else { @@ -1311,6 +1326,7 @@ mod tests { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 83897a0d38..b30d738cd4 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -119,6 +120,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -184,6 +186,7 @@ async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -234,6 +237,7 @@ async fn auth_skips_access_token_outside_refresh_window() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -270,6 +274,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -280,6 +285,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -335,6 +341,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -346,6 +353,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -405,6 +413,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -453,6 +462,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -503,6 +513,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -514,6 +525,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -566,6 +578,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -577,6 +590,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -627,6 +641,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -680,6 +695,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -747,6 +763,7 @@ async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Resu tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -814,6 +831,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -836,6 +854,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -895,6 +914,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -948,6 +968,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -958,6 +979,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1042,6 +1064,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -1053,6 +1076,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1111,6 +1135,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&auth).await?; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 2364ee7f54..2dd1bef83f 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -195,6 +195,7 @@ fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index c20a51a3aa..5f0f840508 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -237,7 +237,12 @@ impl ModelProviderInfo { pub fn to_api_provider(&self, auth_mode: Option) -> CodexResult { let default_base_url = if matches!( auth_mode, - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) { CHATGPT_CODEX_BASE_URL } else { diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index abfa40a36a..a303050dc1 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -139,6 +139,15 @@ fn test_supports_remote_compaction_for_openai() { assert!(provider.supports_remote_compaction()); } +#[test] +fn test_personal_access_token_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::PersonalAccessToken)) + .expect("OpenAI provider should build API provider"); + + assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); +} + #[test] fn test_supports_remote_compaction_for_azure_name() { let provider = ModelProviderInfo { diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 3c7f4dbd0f..4f87d46bae 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -109,13 +109,14 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } - CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { - Arc::new(BearerAuthProvider { - token: auth.get_token().ok(), - account_id: auth.get_account_id(), - is_fedramp_account: auth.is_fedramp_account(), - }) - } + CodexAuth::ApiKey(_) + | CodexAuth::Chatgpt(_) + | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::PersonalAccessToken(_) => Arc::new(BearerAuthProvider { + token: auth.get_token().ok(), + account_id: auth.get_account_id(), + is_fedramp_account: auth.is_fedramp_account(), + }), } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index e2ef3f3650..afd07065d4 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -212,7 +212,8 @@ impl ModelProvider for ConfiguredModelProvider { CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) - | CodexAuth::AgentIdentity(_) => { + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); @@ -453,6 +454,21 @@ mod tests { ); } + #[test] + fn openai_provider_rejects_chatgpt_account_state_without_email() { + let provider = create_model_provider( + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )), + ); + + assert_eq!( + provider.account_state(), + Err(ProviderAccountError::MissingChatgptAccountDetails) + ); + } + #[test] fn custom_non_openai_provider_returns_no_account_state() { let provider = create_model_provider( diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index ac4044ac20..b21911a4d6 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -328,10 +328,9 @@ impl OpenAiModelsManager { .iter() .any(|model| model.visibility == ModelVisibility::List) && self.auth_manager.as_ref().is_some_and(|auth_manager| { - matches!( - auth_manager.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) - ) + auth_manager + .auth_mode() + .is_some_and(AuthMode::has_chatgpt_account) }); if should_use_remote_models_only { *self.remote_models.write().await = models; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index ede7cfe795..2e39ef3160 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -211,6 +211,7 @@ c2ln", }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; std::fs::create_dir_all(codex_home).expect("codex home should be created"); std::fs::write( diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 1a689d3684..586dfa15a7 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -57,7 +57,8 @@ impl From for TelemetryAuthMode { codex_app_server_protocol::AuthMode::ApiKey => Self::ApiKey, codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity => Self::Chatgpt, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken => Self::Chatgpt, } } } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 4aeba59aab..dea6308760 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -87,10 +87,9 @@ impl App { notification.plan_type, ), notification.plan_type, - matches!( - notification.auth_mode, - Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - ), + notification + .auth_mode + .is_some_and(AuthMode::has_chatgpt_account), ); return; } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f5836f4c25..32db3d2cf7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1192,7 +1192,8 @@ pub(crate) fn status_account_display_from_auth_mode( Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey), Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - | Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt { + | Some(AuthMode::AgentIdentity) + | Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt { email: None, plan: plan_type.map(plan_type_display_name), }), diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 270d64f5f7..d7ca01f618 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -109,6 +109,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) .expect("chatgpt auth should save"); @@ -156,6 +157,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0d8a1fa773..e10700ed82 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -10,6 +10,7 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +#[cfg(test)] use codex_app_server_protocol::AuthMode as AppServerAuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientRequest; @@ -845,8 +846,7 @@ impl AuthModeWidget { fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!( self.login_status, - LoginStatus::AuthMode(AppServerAuthMode::Chatgpt) - | LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens) + LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account() ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); @@ -1106,17 +1106,22 @@ mod tests { } #[tokio::test] - async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() { - let (mut widget, _tmp) = widget_forced_chatgpt().await; - widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens); + async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() { + for auth_mode in [ + AppServerAuthMode::ChatgptAuthTokens, + AppServerAuthMode::PersonalAccessToken, + ] { + let (mut widget, _tmp) = widget_forced_chatgpt().await; + widget.login_status = LoginStatus::AuthMode(auth_mode); - let handled = widget.handle_existing_chatgpt_login(); + let handled = widget.handle_existing_chatgpt_login(); - assert_eq!(handled, true); - assert!(matches!( - &*widget.sign_in_state.read().unwrap(), - SignInState::ChatGptSuccess - )); + assert_eq!(handled, true); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::ChatGptSuccess + )); + } } #[tokio::test] From 954e2878bbd735839c13a5e4e7bd45c74648f549 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Fri, 5 Jun 2026 18:01:20 -0700 Subject: [PATCH 094/141] [codex] Send Responses Lite transport header (#26542) ## Summary - send `X-OpenAI-Internal-Codex-Responses-Lite: true` on HTTP Responses requests and WebSocket upgrade requests when model metadata enables Responses Lite - use client metadata when sending it over the websocket This PR is stacked on #26490. ## Why The Responses Lite marker is request-scoped for HTTP but connection-scoped for Responses-over-WebSocket because it is carried on the upgrade request. Reusing a cached socket opened for the opposite mode would therefore send the wrong transport contract. ## Validation - `just test -p codex-core responses_lite` - `just test -p codex-core responses_websocket_reconnects_when_responses_lite_mode_changes` - `just fix -p codex-core` - `just fmt` --- codex-rs/core/src/client.rs | 40 +++++- codex-rs/core/src/client_tests.rs | 5 +- .../core/tests/suite/client_websockets.rs | 115 ++++++++++++++++++ codex-rs/core/tests/suite/responses_lite.rs | 62 ++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 682e84c7ff..dd7b9f400c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -143,7 +143,11 @@ pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = "x-codex-ws-stream-request-start-ms"; +const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = + "ws_request_header_x_openai_internal_codex_responses_lite"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str = + "x-openai-internal-codex-responses-lite"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; // `/responses/compact` is unary, so the timeout covers the full response rather than one idle @@ -526,6 +530,7 @@ impl ModelClient { if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + add_responses_lite_header(&mut extra_headers, model_info.use_responses_lite); let compact_request_timeout = client_setup .api_provider .stream_idle_timeout @@ -655,6 +660,7 @@ impl ModelClient { fn build_ws_client_metadata( &self, turn_metadata_header: Option<&str>, + use_responses_lite: bool, ) -> HashMap { let mut client_metadata = HashMap::new(); client_metadata.insert( @@ -682,6 +688,12 @@ impl ModelClient { turn_metadata.to_string(), ); } + if use_responses_lite { + client_metadata.insert( + WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string(), + "true".to_string(), + ); + } client_metadata } @@ -982,6 +994,7 @@ impl ModelClientSession { &self, turn_metadata_header: Option<&str>, compression: Compression, + use_responses_lite: bool, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); let session_id = self.client.state.session_id.to_string(); @@ -1000,6 +1013,7 @@ impl ModelClientSession { if let Some(header_value) = self.client.generate_attestation_header_for().await { headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + add_responses_lite_header(&mut headers, use_responses_lite); headers }, compression, @@ -1267,7 +1281,11 @@ impl ModelClientSession { ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let mut options = self - .build_responses_options(turn_metadata_header, compression) + .build_responses_options( + turn_metadata_header, + compression, + model_info.use_responses_lite, + ) .await; let request = self.client.build_responses_request( @@ -1377,7 +1395,11 @@ impl ModelClientSession { let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self - .build_responses_options(turn_metadata_header, compression) + .build_responses_options( + turn_metadata_header, + compression, + model_info.use_responses_lite, + ) .await; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1389,7 +1411,10 @@ impl ModelClientSession { )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( - Some(self.client.build_ws_client_metadata(turn_metadata_header)), + Some(self.client.build_ws_client_metadata( + turn_metadata_header, + model_info.use_responses_lite, + )), request_trace.as_ref(), ), ..ResponseCreateWsRequest::from(&request) @@ -1707,6 +1732,15 @@ fn build_responses_headers( headers } +fn add_responses_lite_header(headers: &mut ApiHeaderMap, use_responses_lite: bool) { + if use_responses_lite { + headers.insert( + X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, + HeaderValue::from_static("true"), + ); + } +} + fn subagent_header_value(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(subagent_source) => match subagent_source { diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index f8d904036a..2ebffe3922 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -293,7 +293,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { client.advance_window_generation(); - let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); + let client_metadata = client.build_ws_client_metadata( + Some(r#"{"turn_id":"turn-123"}"#), + /*use_responses_lite*/ false, + ); let thread_id = client.state.thread_id; assert_eq!( client_metadata, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index b28f820569..3e1ed23261 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -62,6 +62,8 @@ const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const USER_AGENT_HEADER: &str = "user-agent"; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const X_CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id"; +const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = + "ws_request_header_x_openai_internal_codex_responses_lite"; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = "x-codex-ws-stream-request-start-ms"; @@ -512,6 +514,85 @@ async fn responses_websocket_reuses_connection_after_session_drop() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_sends_responses_lite_metadata_per_request() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("normal-1"), ev_completed("normal-1")], + vec![ev_response_created("lite-1"), ev_completed("lite-1")], + vec![ev_response_created("normal-2"), ev_completed("normal-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let mut normal_model_info = harness.model_info.clone(); + normal_model_info.supports_reasoning_summaries = true; + let mut lite_model_info = normal_model_info.clone(); + lite_model_info.use_responses_lite = true; + let mut session = harness.client.new_session(); + + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("normal one")]), + &normal_model_info, + "normal-1", + ) + .await; + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("lite")]), + &lite_model_info, + "lite-1", + ) + .await; + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("normal two")]), + &normal_model_info, + "normal-2", + ) + .await; + + let connection = server.single_connection(); + assert_eq!( + connection + .iter() + .map(|request| { + let body = request.body_json(); + json!({ + "responses_lite": body["client_metadata"] + .get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY), + "reasoning_context": body["reasoning"].get("context"), + "parallel_tool_calls": body["parallel_tool_calls"], + }) + }) + .collect::>(), + vec![ + json!({ + "responses_lite": null, + "reasoning_context": null, + "parallel_tool_calls": false, + }), + json!({ + "responses_lite": "true", + "reasoning_context": "all_turns", + "parallel_tool_calls": false, + }), + json!({ + "responses_lite": null, + "reasoning_context": null, + "parallel_tool_calls": false, + }), + ] + ); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { skip_if_no_network!(); @@ -2028,6 +2109,40 @@ async fn stream_until_complete( .await; } +async fn stream_until_complete_with_model_info( + client_session: &mut ModelClientSession, + harness: &WebsocketTestHarness, + prompt: &Prompt, + model_info: &ModelInfo, + expected_response_id: &str, +) { + let mut stream = client_session + .stream( + prompt, + model_info, + &harness.session_telemetry, + harness.effort.clone(), + harness.summary, + /*service_tier*/ None, + /*turn_metadata_header*/ None, + &codex_rollout_trace::InferenceTraceContext::disabled(), + ) + .await + .expect("websocket stream failed"); + + while let Some(event) = stream.next().await { + match event { + Ok(ResponseEvent::Completed { response_id, .. }) => { + assert_eq!(response_id, expected_response_id); + return; + } + Ok(_) => {} + Err(err) => panic!("websocket stream failed: {err}"), + } + } + panic!("websocket stream ended before completion"); +} + async fn stream_until_complete_with_service_tier( client_session: &mut ModelClientSession, harness: &WebsocketTestHarness, diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index c9cbcbffdd..60db4c064d 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -10,12 +10,18 @@ use codex_image_generation_extension::install as install_image_generation_extens use codex_login::CodexAuth; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_web_search_extension::install as install_web_search_extension; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; use serde_json::Value; +const RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite"; + fn responses_extensions(auth: &CodexAuth) -> Arc> { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); let mut extension_builder = ExtensionRegistryBuilder::::new(); @@ -76,6 +82,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res test.submit_turn("Use standalone tools").await?; let request = response_mock.single_request(); + assert_eq!( + request.header(RESPONSES_LITE_HEADER).as_deref(), + Some("true") + ); request .tool_by_name("web", "run") .context("Responses Lite should expose standalone web search")?; @@ -93,6 +103,57 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_compact_request_uses_lite_transport_contract() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let compact_mock = + responses::mount_compact_json_once(&server, serde_json::json!({ "output": [] })).await; + + let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.supports_parallel_tool_calls = true; + }); + let test = builder.build(&server).await?; + + test.submit_turn("Compact this conversation").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + response_mock.single_request(); + let compact_request = compact_mock.single_request(); + assert_eq!( + compact_request.header(RESPONSES_LITE_HEADER).as_deref(), + Some("true") + ); + let compact_body = compact_request.body_json(); + assert_eq!( + compact_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(Value::as_str), + Some("all_turns") + ); + assert_eq!( + compact_body.get("parallel_tool_calls"), + Some(&Value::Bool(false)) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -154,6 +215,7 @@ async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> R test.submit_turn("Use hosted tools").await?; let request = response_mock.single_request(); + assert_eq!(request.header(RESPONSES_LITE_HEADER), None); assert!(request.tool_by_name("web", "run").is_none()); assert!(request.tool_by_name("image_gen", "imagegen").is_none()); let body = request.body_json(); From 2f108f9fd970ed73df7e984d3a661acc02f33abc Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 5 Jun 2026 18:06:29 -0700 Subject: [PATCH 095/141] permissions: enforce managed permission profile allowlists (#24852) ## Why Permission profile allowlists are an enterprise security boundary, but they also need to compose across the managed requirements layers added in #24620. A map representation lets each requirements layer add, allow, or revoke individual profiles without replacing an entire array. ## Managed Contract Administrators configure the mergeable allow map with `allowed_permission_profiles`. A recommended enterprise configuration explicitly lists every built-in and custom profile users should be able to select: ```toml default_permissions = "review_only" [allowed_permission_profiles] ":read-only" = true ":workspace" = true review_only = true # ":danger-full-access" is intentionally omitted, so it is denied. [permissions.review_only] extends = ":read-only" ``` - Profiles whose effective merged value is `true` are allowed. - Missing profiles and profiles set to `false` are denied. - This is a closed allowlist: built-in profiles and profiles introduced in future versions are denied unless explicitly allowed. - Explicitly list each built-in profile the enterprise wants to make available. Omit built-ins such as `:danger-full-access` when they should remain unavailable. - Set `default_permissions` explicitly to the allowed profile users should receive when they have no local selection. - Higher-precedence layers override only the profile keys they define. - `false` is only needed when a higher-precedence layer must revoke a `true` inherited from a lower layer. - Explicit keys must refer to known built-in or managed profiles. A custom or narrowed allowlist requires an allowed `default_permissions`. For compatibility, if both `:workspace` and `:read-only` are explicitly allowed, an omitted default resolves to `:workspace`; customer configurations should still set the intended default explicitly. When `allowed_permission_profiles` is absent, existing implicit permission and legacy `sandbox_mode` behavior is unchanged. ## What Changed - Add `allowed_permission_profiles` as a `BTreeMap` that merges per profile across requirements layers. - Enforce managed defaults, strict denial of omitted profiles, and the explicitly allowed standard-pair fallback. - Expose `allowedPermissionProfiles` through `configRequirements/read` and regenerate its schemas. - Add regression coverage for map composition and revocation, managed defaults, strict denial of omitted built-ins, and API output. ## Verification - Focused `codex-config` coverage for layered map composition and revocation - Focused `codex-core` coverage for managed defaults, invalid defaults, strict denial of omitted built-ins, and the standard built-in pair - Focused `codex-app-server` coverage for requirements API output - Scoped Clippy for `codex-config`, `codex-core`, `codex-app-server-protocol`, and `codex-app-server` ## Documentation The managed `requirements.toml` documentation should introduce `allowed_permission_profiles` as a closed permission-profile allowlist before this setting is published on developers.openai.com. --------- Co-authored-by: Codex --- .../codex_app_server_protocol.schemas.json | 14 +- .../codex_app_server_protocol.v2.schemas.json | 14 +- .../v2/ConfigRequirementsReadResponse.json | 14 +- .../typescript/v2/ConfigRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 3 +- .../src/protocol/v2/tests.rs | 3 +- codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 39 ++- codex-rs/config/src/config_requirements.rs | 65 ++-- .../config/src/requirements_layers/stack.rs | 9 +- .../src/requirements_layers/stack_tests.rs | 16 + .../core/src/config/config_loader_tests.rs | 287 +++++++++++++++--- codex-rs/core/src/config/config_tests.rs | 3 +- codex-rs/core/src/config/mod.rs | 110 +++++-- codex-rs/tui/src/debug_config.rs | 6 +- 15 files changed, 468 insertions(+), 119 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 15ed98e356..0c29f4a097 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8020,12 +8020,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -8066,6 +8066,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ebf8796cd6..b378a54d16 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4362,12 +4362,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -4408,6 +4408,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 0b8170d659..def89d64e3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -94,12 +94,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -140,6 +140,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 3e60e78da5..29704982ff 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -8,4 +8,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f40192ddc6..227adffc30 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -375,7 +375,8 @@ pub struct ConfigRequirements { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_windows_sandbox_implementations: Option>, - pub allowed_permissions: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub allow_appshots: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 1c1e92b36f..4a683be990 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1674,7 +1674,8 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_windows_sandbox_implementations: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3e294d4cf3..d918f70b73 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -231,7 +231,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 3753550925..bdc8013950 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -350,7 +350,8 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .collect() }) }), - allowed_permissions: requirements.allowed_permissions, + allowed_permission_profiles: requirements.allowed_permission_profiles, + default_permissions: requirements.default_permissions, allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { let mut normalized = modes .into_iter() @@ -569,29 +570,43 @@ mod tests { use codex_config::ConfigRequirementsToml; use codex_config::WindowsRequirementsToml; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; #[test] fn requirements_api_includes_allow_managed_hooks_only() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { - allowed_permissions: Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]), allow_managed_hooks_only: Some(true), ..ConfigRequirementsToml::default() }); - assert_eq!( - mapped.allowed_permissions, - Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]) - ); assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_permission_default_and_allowlist() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allowed_permission_profiles: Some(BTreeMap::from([ + ("managed-build".to_string(), false), + ("managed-standard".to_string(), true), + ])), + default_permissions: Some("managed-standard".to_string()), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), false), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + mapped.default_permissions, + Some("managed-standard".to_string()) + ); + } + #[test] fn requirements_api_includes_allow_appshots() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index dcaefb8dbe..1fbdebbd0a 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -822,7 +822,8 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, - pub allowed_permissions: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, @@ -876,7 +877,8 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, - pub allowed_permissions: Option>>, + pub allowed_permission_profiles: Option>>, + pub default_permissions: Option>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -916,7 +918,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, - allowed_permissions: _, + allowed_permission_profiles: _, + default_permissions: _, remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, @@ -951,7 +954,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -983,7 +987,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -1004,7 +1009,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), - allowed_permissions: allowed_permissions.map(|sourced| sourced.value), + allowed_permission_profiles: allowed_permission_profiles.map(|sourced| sourced.value), + default_permissions: default_permissions.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), @@ -1092,7 +1098,8 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() - && self.allowed_permissions.is_none() + && self.allowed_permission_profiles.is_none() + && self.default_permissions.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() @@ -1144,7 +1151,8 @@ impl TryFrom for ConfigRequirements { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions: _, + allowed_permission_profiles: _, + default_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -1511,7 +1519,8 @@ mod tests { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -1536,7 +1545,9 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), - allowed_permissions: allowed_permissions + allowed_permission_profiles: allowed_permission_profiles + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + default_permissions: default_permissions .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1592,7 +1603,11 @@ mod tests { fn deserialize_managed_permission_profiles() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( r#" - allowed_permissions = ["managed-standard", "managed-build"] + default_permissions = "managed-standard" + + [allowed_permission_profiles] + managed-standard = true + managed-build = true [permissions.managed-standard] extends = ":workspace" @@ -1603,11 +1618,15 @@ mod tests { )?; assert_eq!( - requirements.allowed_permissions, - Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]) + requirements.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), true), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + requirements.default_permissions, + Some("managed-standard".to_string()) ); let permissions = requirements .permissions @@ -1720,7 +1739,8 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), - allowed_permissions: Some(vec!["managed".to_string()]), + allowed_permission_profiles: Some(BTreeMap::from([("managed".to_string(), true)])), + default_permissions: Some("managed".to_string()), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), @@ -1753,10 +1773,11 @@ mod tests { source.clone(), )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), - allowed_permissions: Some(Sourced::new( - vec!["managed".to_string()], + allowed_permission_profiles: Some(Sourced::new( + BTreeMap::from([("managed".to_string(), true)]), source.clone(), )), + default_permissions: Some(Sourced::new("managed".to_string(), source.clone(),)), allowed_web_search_modes: Some(Sourced::new( allowed_web_search_modes, enforce_source.clone(), @@ -1809,7 +1830,8 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, @@ -1861,7 +1883,8 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index e93dd1c1da..0396cda237 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -176,7 +176,8 @@ fn populate_merged_regular_fields_with_sources( allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -201,7 +202,11 @@ fn populate_merged_regular_fields_with_sources( &["allowed_approvals_reviewers"] ); set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]); - set_sourced!(allowed_permissions, &["allowed_permissions"]); + set_sourced!( + allowed_permission_profiles, + &["allowed_permission_profiles"] + ); + set_sourced!(default_permissions, &["default_permissions"]); set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]); set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]); set_sourced!(allow_appshots, &["allow_appshots"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 8acfd5f0d5..82a99a7f0a 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -62,6 +62,11 @@ fn top_level_values_use_toml_priority() { r#" allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["workspace-write"] +default_permissions = ":workspace" + +[allowed_permission_profiles] +":read-only" = true +":workspace" = true "#, ), layer( @@ -70,6 +75,11 @@ allowed_sandbox_modes = ["workspace-write"] r#" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] +default_permissions = ":read-only" + +[allowed_permission_profiles] +":danger-full-access" = false +":workspace" = false "#, ), ]) @@ -82,6 +92,12 @@ allowed_sandbox_modes = ["read-only"] r#" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] +default_permissions = ":read-only" + +[allowed_permission_profiles] +":danger-full-access" = false +":read-only" = true +":workspace" = false "# ) ); diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 5edecebec0..87a46fd14a 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -31,7 +31,7 @@ use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -1375,7 +1375,10 @@ default_permissions = "managed-standard" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard] extends = ":workspace" @@ -1397,8 +1400,8 @@ extends = ":workspace" config .config_layer_stack .requirements_toml() - .allowed_permissions, - Some(vec!["managed-standard".to_string()]) + .allowed_permission_profiles, + Some(BTreeMap::from([("managed-standard".to_string(), true)])) ); assert_eq!( config @@ -1411,26 +1414,9 @@ extends = ":workspace" } #[tokio::test] -async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> { - for (trust_level, expected_profile) in [ - ( - Some(TrustLevel::Trusted), - if cfg!(target_os = "windows") { - BUILT_IN_PERMISSION_PROFILE_READ_ONLY - } else { - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - }, - ), - ( - Some(TrustLevel::Untrusted), - if cfg!(target_os = "windows") { - BUILT_IN_PERMISSION_PROFILE_READ_ONLY - } else { - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - }, - ), - (None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY), - ] { +async fn system_allowed_permission_profiles_select_managed_default_without_local_default() +-> anyhow::Result<()> { + for trust_level in [Some(TrustLevel::Trusted), Some(TrustLevel::Untrusted), None] { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1447,10 +1433,17 @@ async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyho tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-build = true +managed-standard = true [permissions.managed-standard.filesystem] ":workspace_roots" = "read" + +[permissions.managed-build] +extends = ":workspace" "#, ) .await?; @@ -1470,30 +1463,189 @@ allowed_permissions = ["managed-standard"] .permissions .active_permission_profile() .map(|profile| profile.id), - Some(expected_profile.to_string()), + Some("managed-standard".to_string()), "trust level {trust_level:?}", ); + assert!( + !config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed")), + "{:?}", + config.startup_warnings + ); } Ok(()) } #[tokio::test] -async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> { +async fn system_allowed_permission_profiles_require_managed_default() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +[permissions.managed-standard] +extends = ":read-only" + +[allowed_permission_profiles] +managed-standard = true +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("allowed_permission_profiles without default_permissions should fail"); + + assert!( + err.to_string().contains( + "default_permissions must be set unless allowed_permission_profiles allows both" + ), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_standard_pair_defaults_to_workspace() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +[allowed_permission_profiles] +":read-only" = true +":workspace" = true +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_managed_default_must_be_allowed() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = "managed-build" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard] +extends = ":read-only" + +[permissions.managed-build] +extends = ":workspace" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("managed default outside allowed_permission_profiles should fail"); + + assert!( + err.to_string().contains( + "default_permissions `managed-build` must be allowed by allowed_permission_profiles" + ), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_managed_default_requires_allowed_permission_profiles() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = ":read-only" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("managed default without allowed_permission_profiles should fail"); + + assert!( + err.to_string() + .contains("default_permissions requires allowed_permission_profiles"), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_fall_back_from_disallowed_danger_full_access() +-> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; tokio::fs::write( codex_home.join(CONFIG_TOML_FILE), - r#" -default_permissions = ":workspace" -"#, + format!( + r#" +default_permissions = "{BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS}" +"# + ), ) .await?; let requirements_path = tmp.path().join("requirements.toml"); tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard.filesystem] ":workspace_roots" = "read" @@ -1516,7 +1668,67 @@ allowed_permissions = ["managed-standard"] .permissions .active_permission_profile() .map(|profile| profile.id), - Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_fall_back_from_disallowed_workspace() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = ":workspace" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings ); Ok(()) } @@ -1538,7 +1750,11 @@ default_permissions = "managed-build" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard", "managed-build"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-build = true +managed-standard = true [permissions.managed-standard] extends = ":read-only" @@ -1579,7 +1795,10 @@ async fn system_requirements_warn_for_disallowed_explicit_permission_override() tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard] extends = ":workspace" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 588eb7ac1b..3a3b490d69 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8351,7 +8351,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e319f3b396..fd74fcf2d2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -119,6 +119,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use crate::config::permissions::BUILT_IN_READ_ONLY_PROFILE; use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; @@ -3835,7 +3836,9 @@ fn resolve_effective_permission_selection<'a>( Ok(EffectivePermissionSelection { profiles, selected_profile_id, - requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(), + requirements_force_profile_selection: requirements_toml + .allowed_permission_profiles + .is_some(), }) } @@ -3845,28 +3848,36 @@ fn resolve_default_permissions<'a>( requirements_toml: &'a ConfigRequirementsToml, startup_warnings: &mut Vec, ) -> std::io::Result> { - let allowed_permissions = requirements_toml.allowed_permissions.as_ref(); - let mut default_permissions = default_permissions_override.or(configured_default_permissions); - if let (Some(selected_permissions), Some(allowed_permissions)) = - (default_permissions, allowed_permissions) - && !is_builtin_permission_profile_name(selected_permissions) - && !allowed_permissions - .iter() - .any(|allowed_permission| allowed_permission == selected_permissions) - { - let Some(fallback_permissions) = allowed_permissions.first().map(String::as_str) else { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "requirements.toml allowed_permissions must include at least one profile", - )); - }; - startup_warnings.push(format!( - "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + let selected_permissions = default_permissions_override.or(configured_default_permissions); + let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref() + else { + return Ok(selected_permissions); + }; + let Some(fallback_permissions) = requirements_toml + .default_permissions + .as_deref() + .or_else(|| implicit_default_permissions(allowed_permission_profiles)) + else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`", )); - default_permissions = Some(fallback_permissions); - } + }; - Ok(default_permissions) + match selected_permissions { + None => Ok(Some(fallback_permissions)), + Some(selected_permissions) + if is_permission_allowed(allowed_permission_profiles, selected_permissions) => + { + Ok(Some(selected_permissions)) + } + Some(selected_permissions) => { + startup_warnings.push(format!( + "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + )); + Ok(Some(fallback_permissions)) + } + } } fn validate_required_permission_profile_catalog( @@ -3880,30 +3891,67 @@ fn validate_required_permission_profile_catalog( .is_some_and(|permissions| permissions.entries.contains_key(profile_id)) }; - let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else { + let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref() + else { + if requirements_toml.default_permissions.is_some() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions requires allowed_permission_profiles", + )); + } return Ok(()); }; - if allowed_permissions.is_empty() { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "requirements.toml allowed_permissions must include at least one profile", - )); - } - - for profile_id in allowed_permissions { + for profile_id in allowed_permission_profiles.keys() { if !is_known_profile(profile_id) { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!( - "requirements.toml allowed_permissions refers to undefined profile `{profile_id}`" + "requirements.toml allowed_permission_profiles refers to undefined profile `{profile_id}`" ), )); } } + let Some(default_permissions) = requirements_toml + .default_permissions + .as_deref() + .or_else(|| implicit_default_permissions(allowed_permission_profiles)) + else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`", + )); + }; + if !is_permission_allowed(allowed_permission_profiles, default_permissions) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml default_permissions `{default_permissions}` must be allowed by allowed_permission_profiles" + ), + )); + } + Ok(()) } +fn implicit_default_permissions( + allowed_permission_profiles: &BTreeMap, +) -> Option<&'static str> { + (is_permission_allowed(allowed_permission_profiles, BUILT_IN_WORKSPACE_PROFILE) + && is_permission_allowed(allowed_permission_profiles, BUILT_IN_READ_ONLY_PROFILE)) + .then_some(BUILT_IN_WORKSPACE_PROFILE) +} + +fn is_permission_allowed( + allowed_permission_profiles: &BTreeMap, + profile_id: &str, +) -> bool { + allowed_permission_profiles + .get(profile_id) + .copied() + .unwrap_or(false) +} + fn normalize_guardian_policy_config(value: Option<&str>) -> Option { value.and_then(|value| { let trimmed = value.trim(); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 562df95989..de5f79a676 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -702,7 +702,8 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), @@ -973,7 +974,8 @@ approval_policy = "never" allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, From cbac22dabec0dd043c670b54de397a3be5fc0133 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Jun 2026 18:37:47 -0700 Subject: [PATCH 096/141] [codex] Deduplicate skill load warnings (#26698) Skill reloads can get noisy when the watcher keeps triggering `skills/list` and the same invalid `SKILL.md` error comes back each time. This keeps the first warning visible, then suppresses repeats while the same `(path, message)` is still active. If the error clears and later comes back, or if the message changes, it will show again. Validation: - `just fmt` - `just test -p codex-tui skill_load_warning_state` --- codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app/history_ui.rs | 1 + codex-rs/tui/src/app/startup_prompts.rs | 163 ++++++++++++++++++++++++ codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 30 +++++ codex-rs/tui/src/app/thread_routing.rs | 1 + 6 files changed, 199 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index aae2cba954..4b86eda898 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -513,6 +513,8 @@ pub(crate) struct App { status_line_invalid_items_warned: Arc, // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. terminal_title_invalid_items_warned: Arc, + // Tracks active skill-load warnings so refreshes do not duplicate history cells. + skill_load_warnings: SkillLoadWarningState, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -1015,6 +1017,7 @@ See the Codex keymap documentation for supported actions and examples." commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 4cb5072daf..0aff920e6d 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -113,6 +113,7 @@ impl App { self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; + self.skill_load_warnings.clear(); } } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index a027dcd505..4834656617 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -4,6 +4,45 @@ //! catalog state into one-time TUI prompts or warning cells without owning the main event loop. use super::*; +use std::collections::HashSet; +use std::path::PathBuf; + +#[derive(Debug, PartialEq, Eq, Hash)] +struct SkillLoadWarningKey { + path: PathBuf, + message: String, +} + +#[derive(Debug, Default)] +pub(super) struct SkillLoadWarningState { + active: HashSet, +} + +impl SkillLoadWarningState { + pub(super) fn clear(&mut self) { + self.active.clear(); + } + + pub(super) fn newly_active_errors(&mut self, errors: &[SkillErrorInfo]) -> Vec { + let previous = std::mem::take(&mut self.active); + let mut current = HashSet::new(); + let mut newly_active = Vec::new(); + + for error in errors { + let key = SkillLoadWarningKey { + path: error.path.clone(), + message: error.message.clone(), + }; + let was_active = previous.contains(&key); + if current.insert(key) && !was_active { + newly_active.push(error.clone()); + } + } + + self.active = current; + newly_active + } +} pub(super) fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { if errors.is_empty() { @@ -336,8 +375,10 @@ mod tests { use super::*; use crate::test_support::PathBufExt; use pretty_assertions::assert_eq; + use ratatui::text::Line; use std::path::PathBuf; use tempfile::tempdir; + use tokio::sync::mpsc::unbounded_channel; #[test] fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { @@ -357,4 +398,126 @@ mod tests { ); Ok(()) } + + fn skill_error(path: &str, message: &str) -> SkillErrorInfo { + SkillErrorInfo { + path: PathBuf::from(path), + message: message.to_string(), + } + } + + fn render_line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn render_skill_load_warning_cells(errors: &[SkillErrorInfo]) -> String { + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + + emit_skill_load_warnings(&app_event_tx, errors); + + let mut rendered = Vec::new(); + while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() { + rendered.extend( + cell.display_lines(/*width*/ 120) + .iter() + .map(render_line_text), + ); + } + rendered.join("\n") + } + + #[test] + fn skill_load_warning_state_suppresses_repeated_active_errors() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + } + + #[test] + fn skill_load_warning_state_reemits_after_error_clears() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!(state.newly_active_errors(&[]), Vec::::new()); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); + } + + #[test] + fn skill_load_warning_state_displays_new_message_for_active_path() { + let mut state = SkillLoadWarningState::default(); + let initial = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + let changed = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid frontmatter"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&initial)), + vec![initial] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&changed)), + vec![changed] + ); + } + + #[test] + fn skill_load_warning_state_clear_allows_active_error_again() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + + state.clear(); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); + } + + #[test] + fn repeated_active_skill_load_warning_renders_once() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + let first_errors = state.newly_active_errors(std::slice::from_ref(&error)); + let repeated_errors = state.newly_active_errors(std::slice::from_ref(&error)); + let rendered = [ + render_skill_load_warning_cells(&first_errors), + render_skill_load_warning_cells(&repeated_errors), + ] + .into_iter() + .filter(|output| !output.is_empty()) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" +⚠ Skipped loading 1 skill(s) due to invalid SKILL.md files. +⚠ /repo/.codex/skills/abc/SKILL.md: invalid description +"); + } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9bbe0c60b4..c59102fa6d 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -39,6 +39,7 @@ pub(super) async fn make_test_app() -> App { commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index b18726004e..2e3e1b8c90 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3788,6 +3788,7 @@ async fn make_test_app() -> App { commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -3851,6 +3852,7 @@ async fn make_test_app_with_channels() -> ( commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -5576,6 +5578,34 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); } +#[tokio::test] +async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() { + let mut app = make_test_app().await; + let error = SkillErrorInfo { + path: test_path_buf("/tmp/project/.codex/skills/abc/SKILL.md"), + message: "invalid description".to_string(), + }; + + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + + app.reset_app_ui_state_after_clear(); + + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); +} + #[tokio::test] async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c53cfc05e3..7e9f91befd 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1342,6 +1342,7 @@ impl App { pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, &response); + let errors = self.skill_load_warnings.newly_active_errors(&errors); emit_skill_load_warnings(&self.app_event_tx, &errors); self.chat_widget.handle_skills_list_response(response); } From 3ea9e983334ce187b295359d19b17706b7dd1144 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 5 Jun 2026 18:53:12 -0700 Subject: [PATCH 097/141] Remove `just bench-smoke` from `just test`. (#26716) ## Why `just test` should run the test suite without also compiling and executing benchmark smoke tests. Keeping benchmark validation explicit avoids adding unrelated work to every project-specific test invocation. ## What changed - Remove the `just bench-smoke` step from the Unix and Windows `test` recipes. - Document `just bench` and `just bench-smoke` as the explicit benchmark commands in `AGENTS.md`. ## Validation - `just test -p codex-arg0` - `just --dry-run test` - `just --dry-run bench-smoke` --- AGENTS.md | 6 ++++++ justfile | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4666566f96..46d2626e32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,12 @@ If you don’t have the tool: - `cargo install --locked cargo-insta` +### Benchmarks + +cargo benchmarks can be run with `just bench`, use the divan crate to write new ones. + +Use `just bench-smoke` to dry-run the benchmark for a single iteration to ensure it works. + ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. diff --git a/justfile b/justfile index fe7e7349b3..3b993b767d 100644 --- a/justfile +++ b/justfile @@ -76,12 +76,10 @@ install: [unix] test *args: RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@" - just bench-smoke [windows] test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) - just bench-smoke # Run from the repository root so scripts that resolve paths from `cwd` see # the same layout they use in GitHub Actions. From 87b808bb570f01f4b6fc8485c5459052fac0e320 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Fri, 5 Jun 2026 22:54:57 -0700 Subject: [PATCH 098/141] fix(remote-control): preserve enrollment on generic websocket 404s (#26741) ## Why A remote-control WebSocket handshake can receive a generic HTTP 404 when an intermediary routes the request without preserving the WebSocket upgrade. Treating every 404 as proof that the remote app server is gone clears valid enrollment and causes repeated re-enrollment, new environment and server IDs, Habitat churn, and noisy `/server/enroll` traffic. ## What Changed - Clear enrollment only when a 404 JSON response explicitly contains `{"detail":"Remote app server not found"}`. - Preserve enrollment for empty, plain-text, malformed, or otherwise unrecognized 404 responses, return the transport error, and retry with the existing reconnect backoff. - Log the status, correlation headers (`request-id` or `x-oai-request-id`, plus `cf-ray`), and bounded/redacted response body for unrecognized 404s. - Cover both explicit missing-server re-enrollment and generic 404 enrollment preservation/reconnect behavior. ## Verification `just test -p codex-app-server-transport` passes all 114 tests on the rebased branch, including the targeted explicit and generic WebSocket 404 scenarios. Related issue: N/A --- .../src/transport/remote_control/tests.rs | 139 +++++++++++++++++- .../src/transport/remote_control/websocket.rs | 74 +++++++++- 2 files changed, 210 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 69659e84a1..1e653c2969 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1744,7 +1744,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen } #[tokio::test] -async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() { +async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -1831,7 +1831,12 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() Some("env_stale"), ) .await; - respond_with_status(websocket_request.stream, "404 Not Found", "").await; + respond_with_status( + websocket_request.stream, + "404 Not Found", + &json!({"detail": "Remote app server not found"}).to_string(), + ) + .await; expect_remote_control_status( &mut status_rx, /*expected_status*/ None, @@ -1881,6 +1886,136 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() let _ = remote_task.await; } +#[tokio::test] +async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_404() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let stale_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), + account_id: "account_id".to_string(), + environment_id: "env_stale".to_string(), + server_id: "srv_e_stale".to_string(), + server_name: "stale-server".to_string(), + remote_control_token: None, + expires_at: None, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + &stale_enrollment.server_id, + &stale_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let websocket_request = accept_http_request(&listener).await; + assert_eq!( + websocket_request.request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + assert_eq!( + websocket_request.headers.get("x-codex-server-id"), + Some(&stale_enrollment.server_id) + ); + assert_eq!( + websocket_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_stale"), + ) + .await; + respond_with_status_and_headers( + websocket_request.stream, + "404 Not Found", + &[("x-request-id", "request-404"), ("cf-ray", "ray-404")], + "Not Found", + ) + .await; + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("stale enrollment should load"), + Some(stale_enrollment.clone()) + ); + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&stale_enrollment.server_id) + ); + assert_eq!( + handshake_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + expect_remote_control_status_snapshot( + &mut status_rx, + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connected, + server_name: test_server_name(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: Some("env_stale".to_string()), + }, + ) + .await; + + shutdown_token.cancel(); + let _ = remote_task.await; +} + #[derive(Debug)] struct CapturedHttpRequest { stream: TcpStream, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 86b72849d3..e0208b1f1c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -77,6 +77,7 @@ const REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const REMOTE_CONTROL_CONNECTION_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const REMOTE_APP_SERVER_NOT_FOUND_DETAIL: &str = "Remote app server not found"; struct BoundedOutboundBuffer { buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque>, @@ -1248,7 +1249,9 @@ pub(super) async fn connect_remote_control_websocket( Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), Err(err) => { match &err { - tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + tungstenite::Error::Http(response) + if websocket_response_reports_missing_remote_app_server(response) => + { info!( "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", remote_control_target.websocket_url, @@ -1267,6 +1270,23 @@ pub(super) async fn connect_remote_control_websocket( ) .await; } + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + let response_body = response + .body() + .as_deref() + .map(preview_remote_control_response_body) + .unwrap_or_else(|| "".to_string()); + warn!( + websocket_url = %remote_control_target.websocket_url, + account_id = %auth.account_id, + server_id = %enrollment.server_id, + environment_id = %enrollment.environment_id, + response_status = %response.status(), + response_headers = %format_headers(response.headers()), + response_body = %response_body, + "remote control websocket returned unrecognized HTTP 404; preserving enrollment before retry" + ); + } tungstenite::Error::Http(response) if matches!(response.status().as_u16(), 401 | 403) => { @@ -1434,6 +1454,18 @@ async fn prepare_remote_control_enrollment( Ok(auth) } +fn websocket_response_reports_missing_remote_app_server( + response: &tungstenite::http::Response>>, +) -> bool { + response.status().as_u16() == 404 + && response.body().as_deref().is_some_and(|body| { + serde_json::from_slice::(body).is_ok_and(|body| { + body.get("detail").and_then(serde_json::Value::as_str) + == Some(REMOTE_APP_SERVER_NOT_FOUND_DETAIL) + }) + }) +} + async fn clear_remote_control_enrollment( state_db: &StateRuntime, remote_control_target: &RemoteControlTarget, @@ -1673,6 +1705,46 @@ mod tests { assert_eq!(reconnect_attempt, 1); } + #[test] + fn websocket_404_only_reports_explicit_missing_remote_app_server() { + let cases = [ + ( + Some(br#"{"detail":"Remote app server not found"}"#.to_vec()), + true, + ), + ( + Some(br#" { "detail": "Remote app server not found", "extra": true } "#.to_vec()), + true, + ), + (Some(br#"{"detail":"Not Found"}"#.to_vec()), false), + (Some(b"Not Found".to_vec()), false), + (Some(b"{".to_vec()), false), + (Some(Vec::new()), false), + (None, false), + ]; + + for (body, expected) in cases { + let response = tungstenite::http::Response::builder() + .status(/*status*/ 404) + .body(body) + .expect("response should build"); + assert_eq!( + websocket_response_reports_missing_remote_app_server(&response), + expected + ); + } + + let response = tungstenite::http::Response::builder() + .status(/*status*/ 503) + .body(Some( + br#"{"detail":"Remote app server not found"}"#.to_vec(), + )) + .expect("response should build"); + assert!(!websocket_response_reports_missing_remote_app_server( + &response + )); + } + fn remote_control_status_channel() -> ( RemoteControlStatusPublisher, watch::Receiver, From cdc8ec065e224c74683cf3a134772e64fb0c0c63 Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Sat, 6 Jun 2026 14:03:00 -0700 Subject: [PATCH 099/141] fix(core-plugins): send Codex product SKU to plugin-service (#26804) --- codex-rs/app-server/tests/suite/v2/plugin_list.rs | 4 ++++ codex-rs/core-plugins/src/remote.rs | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 49ad3c418e..d9a0a00493 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1723,6 +1723,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and(query_param("limit", "200")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) + .and(header("oai-product-sku", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body)) .mount(&server) .await; @@ -1732,6 +1733,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and(query_param("limit", "200")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) + .and(header("oai-product-sku", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) .mount(&server) .await; @@ -1740,6 +1742,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and(query_param("scope", "GLOBAL")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) + .and(header("oai-product-sku", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body)) .mount(&server) .await; @@ -1748,6 +1751,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and(query_param("scope", "WORKSPACE")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) + .and(header("oai-product-sku", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) .mount(&server) .await; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 7daf1da31f..b96746b637 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -64,6 +64,8 @@ pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &st "Shared with me (unlisted)"; const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical"; +const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; +const CODEX_PRODUCT_SKU: &str = "codex"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3; @@ -1591,7 +1593,8 @@ fn authenticated_request( ) -> Result { Ok(request .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) - .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()) + .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU)) } async fn send_and_decode Deserialize<'de>>( From b89ce9a2bcedcfddf3a48f387b7912d602d6d87c Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sat, 6 Jun 2026 14:27:23 -0700 Subject: [PATCH 100/141] build(v8): update rusty_v8 to 149.2.0 (#26464) --- .bazelrc | 2 + .github/workflows/rusty-v8-release.yml | 156 +++++++++++++++++ .github/workflows/v8-canary.yml | 16 ++ MODULE.bazel | 32 ++-- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 4 +- codex-rs/Cargo.toml | 2 +- patches/v8_bazel_rules.patch | 75 ++++---- patches/v8_module_deps.patch | 149 +++++++++------- patches/v8_source_portability.patch | 78 ++++----- third_party/v8/BUILD.bazel | 230 ++++++++++++------------- third_party/v8/README.md | 40 +++-- third_party/v8/libcxx.BUILD.bazel | 3 +- third_party/v8/libcxxabi.BUILD.bazel | 1 + third_party/v8/rusty_v8_147_4_0.sha256 | 2 - third_party/v8/rusty_v8_149_2_0.sha256 | 2 + 16 files changed, 495 insertions(+), 299 deletions(-) delete mode 100644 third_party/v8/rusty_v8_147_4_0.sha256 create mode 100644 third_party/v8/rusty_v8_149_2_0.sha256 diff --git a/.bazelrc b/.bazelrc index c8b8682c4b..c58667b46e 100644 --- a/.bazelrc +++ b/.bazelrc @@ -214,6 +214,8 @@ common --@v8//:v8_enable_sandbox=True # artifact migration ships matching Rust feature selection for Cargo consumers. common:v8-release-compat --@v8//:v8_enable_pointer_compression=False common:v8-release-compat --@v8//:v8_enable_sandbox=False +common:v8-target-x64 --@v8//bazel/config:v8_target_cpu=x64 +common:v8-target-arm64 --@v8//bazel/config:v8_target_cpu=arm64 # Match rusty_v8's upstream GN release contract for published artifacts: every # target object uses Chromium's custom libc++ headers and the archive folds in diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index d6fb73e96d..38f36856a8 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -73,72 +73,84 @@ jobs: platform: linux_amd64 sandbox: false target: x86_64-unknown-linux-gnu + v8_cpu: x64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64 sandbox: true target: x86_64-unknown-linux-gnu + v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: false target: aarch64-unknown-linux-gnu + v8_cpu: arm64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: true target: aarch64-unknown-linux-gnu + v8_cpu: arm64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: false target: x86_64-apple-darwin + v8_cpu: x64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: true target: x86_64-apple-darwin + v8_cpu: x64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: false target: aarch64-apple-darwin + v8_cpu: arm64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: true target: aarch64-apple-darwin + v8_cpu: arm64 variant: ptrcomp-sandbox - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: false target: x86_64-unknown-linux-musl + v8_cpu: x64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: false target: aarch64-unknown-linux-musl + v8_cpu: arm64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: true target: x86_64-unknown-linux-musl + v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: true target: aarch64-unknown-linux-musl + v8_cpu: arm64 variant: ptrcomp-sandbox steps: @@ -167,6 +179,7 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} + V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -184,6 +197,7 @@ jobs: opt "--platforms=@llvm//platforms:${PLATFORM}" --config=rusty-v8-upstream-libcxx + "--config=v8-target-${V8_CPU}" "${pair_target}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) @@ -203,6 +217,7 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} + V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -213,6 +228,7 @@ jobs: --compilation-mode opt --output-dir "dist/${TARGET}" --bazel-config "${BAZEL_CONFIG}" + --bazel-config "v8-target-${V8_CPU}" ) if [[ "${SANDBOX}" == "true" ]]; then stage_args+=(--sandbox) @@ -266,10 +282,150 @@ jobs: name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.variant }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* + build-windows-source: + name: Build ptrcomp-sandbox ${{ matrix.target }} from source + needs: metadata + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + target: x86_64-pc-windows-msvc + - runner: windows-2022 + target: aarch64-pc-windows-msvc + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Configure git for upstream checkout + shell: bash + run: git config --global core.symlinks true + + - name: Check out upstream rusty_v8 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: denoland/rusty_v8 + ref: v${{ needs.metadata.outputs.v8_version }} + path: upstream-rusty-v8 + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + architecture: x64 + + - name: Set up Codex Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + toolchain: "1.95.0" + targets: ${{ matrix.target }} + + - name: Install rusty_v8 Rust toolchain + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.91.0 --profile minimal --no-self-update + rustup target add --toolchain 1.91.0 "${TARGET}" + + - name: Write upstream submodule status + shell: bash + working-directory: upstream-rusty-v8 + run: git submodule status --recursive > git_submodule_status.txt + + - name: Restore upstream source-build cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + upstream-rusty-v8/target/sccache + upstream-rusty-v8/target/${{ matrix.target }}/release/gn_out + key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} + restore-keys: | + rusty-v8-source-${{ matrix.target }}-sandbox- + + - name: Install and start sccache + shell: pwsh + env: + SCCACHE_CACHE_SIZE: 256M + SCCACHE_DIR: ${{ github.workspace }}/upstream-rusty-v8/target/sccache + SCCACHE_IDLE_TIMEOUT: 0 + run: | + $version = "v0.8.2" + $platform = "x86_64-pc-windows-msvc" + $basename = "sccache-$version-$platform" + $url = "https://github.com/mozilla/sccache/releases/download/$version/$basename.tar.gz" + cd ~ + curl -LO $url + tar -xzvf "$basename.tar.gz" + . $basename/sccache --start-server + echo "$(pwd)/$basename" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install Chromium clang for ARM64 MSVC cross build + if: matrix.target == 'aarch64-pc-windows-msvc' + shell: bash + working-directory: upstream-rusty-v8 + run: python3 tools/clang/scripts/update.py + + - name: Build upstream rusty_v8 sandbox release pair + env: + SCCACHE_IDLE_TIMEOUT: 0 + TARGET: ${{ matrix.target }} + V8_FROM_SOURCE: "1" + shell: bash + working-directory: upstream-rusty-v8 + run: cargo +1.91.0 build --locked --release --target "${TARGET}" --features v8_enable_sandbox + + - name: Stage upstream sandbox release pair + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \ + --source-root upstream-rusty-v8 \ + --target "${TARGET}" \ + --output-dir "dist/${TARGET}" \ + --sandbox + + - name: Smoke link staged artifact with Cargo + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'rusty_v8_*.lib.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + ( + cd codex-rs + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo +1.95.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run + ) + + - name: Upload staged artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-ptrcomp-sandbox-${{ matrix.target }} + path: dist/${{ matrix.target }}/* + publish-release: needs: - metadata - build + - build-windows-source runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 0ad9f850d1..c590dec00f 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -89,72 +89,84 @@ jobs: platform: linux_amd64 sandbox: false target: x86_64-unknown-linux-gnu + v8_cpu: x64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64 sandbox: true target: x86_64-unknown-linux-gnu + v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: false target: aarch64-unknown-linux-gnu + v8_cpu: arm64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: true target: aarch64-unknown-linux-gnu + v8_cpu: arm64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: false target: x86_64-apple-darwin + v8_cpu: x64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: true target: x86_64-apple-darwin + v8_cpu: x64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: false target: aarch64-apple-darwin + v8_cpu: arm64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: true target: aarch64-apple-darwin + v8_cpu: arm64 variant: ptrcomp-sandbox - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: false target: x86_64-unknown-linux-musl + v8_cpu: x64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: true target: x86_64-unknown-linux-musl + v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: false target: aarch64-unknown-linux-musl + v8_cpu: arm64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: true target: aarch64-unknown-linux-musl + v8_cpu: arm64 variant: ptrcomp-sandbox steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -183,6 +195,7 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} + V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -198,6 +211,7 @@ jobs: build "--platforms=@llvm//platforms:${PLATFORM}" --config=rusty-v8-upstream-libcxx + "--config=v8-target-${V8_CPU}" "${pair_target}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) @@ -217,6 +231,7 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} + V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -226,6 +241,7 @@ jobs: --target "${TARGET}" --output-dir "dist/${TARGET}" --bazel-config "${BAZEL_CONFIG}" + --bazel-config "v8-target-${V8_CPU}" ) if [[ "${SANDBOX}" == "true" ]]; then stage_args+=(--sandbox) diff --git a/MODULE.bazel b/MODULE.bazel index abca889d64..c4ef9500a7 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -415,18 +415,18 @@ crate.annotation( inject_repo(crate, "alsa_lib") -bazel_dep(name = "v8", version = "14.7.173.20") +bazel_dep(name = "v8", version = "14.9.207.2") archive_override( module_name = "v8", - integrity = "sha256-v/x6I4X38a2wckzUIft3Dh0SUdkuOTokwxyF7lzW8Lc=", + integrity = "sha256-tflbZE5srqal6leMxJjK/ZQtwpF96OMGJ6avd5lice4=", patch_strip = 3, patches = [ "//patches:v8_module_deps.patch", "//patches:v8_bazel_rules.patch", "//patches:v8_source_portability.patch", ], - strip_prefix = "v8-14.7.173.20", - urls = ["https://github.com/v8/v8/archive/refs/tags/14.7.173.20.tar.gz"], + strip_prefix = "v8-14.9.207.2", + urls = ["https://github.com/v8/v8/archive/refs/tags/14.9.207.2.tar.gz"], ) http_archive( @@ -439,12 +439,12 @@ http_archive( ) http_archive( - name = "v8_crate_147_4_0", + name = "v8_crate_149_2_0", build_file = "//third_party/v8:v8_crate.BUILD.bazel", - sha256 = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd", - strip_prefix = "v8-147.4.0", + sha256 = "46dccf61a364b61bbaac70a8ba64a1a1006e87123b7d62eaeec999a3ba31ecdb", + strip_prefix = "v8-149.2.0", type = "tar.gz", - urls = ["https://static.crates.io/crates/v8/v8-147.4.0.crate"], + urls = ["https://static.crates.io/crates/v8/v8-149.2.0.crate"], ) git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") @@ -452,7 +452,7 @@ git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "g git_repository( name = "rusty_v8_libcxx", build_file = "//third_party/v8:libcxx.BUILD.bazel", - commit = "7ab65651aed6802d2599dcb7a73b1f82d5179d05", + commit = "99457fa555797f8c5ac3c076ca288d8481d3b23a", remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxx.git", ) @@ -466,25 +466,25 @@ git_repository( git_repository( name = "rusty_v8_llvm_libc", build_file = "//third_party/v8:llvm_libc.BUILD.bazel", - commit = "b3aa5bb702ff9e890179fd1e7d3ba346e17ecf8e", + commit = "cb952785ccee13811f293f3c419958d1e3ddafbf", remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libc.git", ) http_file( - name = "rusty_v8_147_4_0_aarch64_pc_windows_msvc_archive", + name = "rusty_v8_149_2_0_aarch64_pc_windows_msvc_archive", downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", - sha256 = "1fa3f94d9e09cff1f6bcce94c478e5cb072c0755f6a0357abadb9dd3b48d8127", + sha256 = "923f2b6ccdc14526b814e171e34c9aafd7969f12304948857c6696d022f0fb3c", urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v147.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", + "https://github.com/denoland/rusty_v8/releases/download/v149.2.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", ], ) http_file( - name = "rusty_v8_147_4_0_x86_64_pc_windows_msvc_archive", + name = "rusty_v8_149_2_0_x86_64_pc_windows_msvc_archive", downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", - sha256 = "e2827ff98b1a9d4c0343000fc5124ac30dfab3007bc0129c168c9355fc2fcd7c", + sha256 = "12b5a791b54e92f748738ad8d0d12dad8d281a2d836638ad1aa6678e3b855d9a", urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v147.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", + "https://github.com/denoland/rusty_v8/releases/download/v149.2.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", ], ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index f541a2c7bf..c55ceb6e01 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1704,7 +1704,7 @@ "utf8_iter_1.0.4": "{\"dependencies\":[],\"features\":{}}", "utf8parse_0.2.2": "{\"dependencies\":[],\"features\":{\"default\":[],\"nightly\":[]}}", "uuid_1.20.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"atomic\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.20.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"md-5\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.56\"},{\"default_features\":false,\"name\":\"sha1_smol\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"slog\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.52\"},{\"name\":\"uuid-rng-internal-lib\",\"optional\":true,\"package\":\"uuid-rng-internal\",\"req\":\"^1.20.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"default_features\":false,\"features\":[\"msrv\"],\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"atomic\":[\"dep:atomic\"],\"borsh\":[\"dep:borsh\",\"dep:borsh-derive\"],\"default\":[\"std\"],\"fast-rng\":[\"rng\",\"dep:rand\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"macro-diagnostics\":[],\"md5\":[\"dep:md-5\"],\"rng\":[\"dep:getrandom\"],\"rng-getrandom\":[\"rng\",\"dep:getrandom\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/getrandom\"],\"rng-rand\":[\"rng\",\"dep:rand\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/rand\"],\"serde\":[\"dep:serde_core\"],\"sha1\":[\"dep:sha1_smol\"],\"std\":[\"wasm-bindgen?/std\",\"js-sys?/std\"],\"v1\":[\"atomic\"],\"v3\":[\"md5\"],\"v4\":[\"rng\"],\"v5\":[\"sha1\"],\"v6\":[\"atomic\"],\"v7\":[\"rng\"],\"v8\":[]}}", - "v8_147.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"simdutf\":[],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", + "v8_149.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"simdutf\":[],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", "valuable_0.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"valuable-derive\",\"optional\":true,\"req\":\"=0.1.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"derive\":[\"valuable-derive\"],\"std\":[\"alloc\"]}}", "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", "version-compare_0.2.1": "{\"dependencies\":[],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d67e418298..ded51c95b2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -14068,9 +14068,9 @@ dependencies = [ [[package]] name = "v8" -version = "147.4.0" +version = "149.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd" +checksum = "46dccf61a364b61bbaac70a8ba64a1a1006e87123b7d62eaeec999a3ba31ecdb" dependencies = [ "bindgen", "bitflags 2.10.0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5fe0104321..9d870e29be 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -424,7 +424,7 @@ unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" -v8 = "=147.4.0" +v8 = "=149.2.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 97e07d9d47..fce2be2894 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -3,11 +3,11 @@ # Scope: Bazel BUILD/defs/BUILD.icu integration only, including dependency # wiring, generated sources, and visibility; no standalone V8 source patching. -diff --git a/orig/v8-14.6.202.11/bazel/defs.bzl b/mod/v8-14.6.202.11/bazel/defs.bzl -index 9648e4a..88efd41 100644 ---- a/orig/v8-14.6.202.11/bazel/defs.bzl -+++ b/mod/v8-14.6.202.11/bazel/defs.bzl -@@ -33,9 +33,21 @@ +diff --git a/orig/v8-14.9.207.2/bazel/defs.bzl b/mod/v8-14.9.207.2/bazel/defs.bzl +index bbe1495..6673518 100644 +--- a/orig/v8-14.9.207.2/bazel/defs.bzl ++++ b/mod/v8-14.9.207.2/bazel/defs.bzl +@@ -33,9 +33,21 @@ _create_option_int = rule( ) def v8_flag(name, default = False): @@ -32,7 +32,7 @@ index 9648e4a..88efd41 100644 def v8_string(name, default = ""): _create_option_string(name = name, build_setting_default = default) -@@ -97,7 +109,13 @@ +@@ -97,7 +109,13 @@ v8_config = rule( def _default_args(): return struct( @@ -47,7 +47,7 @@ index 9648e4a..88efd41 100644 defines = select({ "@v8//bazel/config:is_windows": [ "UNICODE", -@@ -127,12 +145,15 @@ +@@ -127,12 +145,15 @@ def _default_args(): ], "//conditions:default": [], }) + select({ @@ -56,20 +56,19 @@ index 9648e4a..88efd41 100644 - "-Wno-deprecated-this-capture", - "-Wno-deprecated-declarations", - "-std=c++20", -- ], + "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ + "-nostdinc++", + "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", + "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", + "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", + "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", -+ ], + ], + "//conditions:default": [], + }) + select({ "@v8//bazel/config:is_gcc": [ "-Wno-extra", "-Wno-array-bounds", -@@ -152,9 +173,17 @@ +@@ -152,9 +173,17 @@ def _default_args(): "-std=gnu++2a", ], "@v8//bazel/config:is_windows": [ @@ -78,18 +77,18 @@ index 9648e4a..88efd41 100644 + "-Wno-deprecated-this-capture", + "-Wno-deprecated-declarations", + "-std=c++20", - ], -- "//conditions:default": [], ++ ], + "//conditions:default": [ + "-Wno-invalid-offsetof", + "-Wno-deprecated-this-capture", + "-Wno-deprecated-declarations", + "-std=c++20", -+ ], + ], +- "//conditions:default": [], }) + select({ "@v8//bazel/config:is_gcc_fastbuild": [ # Non-debug builds without optimizations fail because -@@ -178,12 +207,12 @@ +@@ -178,12 +207,12 @@ def _default_args(): includes = ["include"], linkopts = select({ "@v8//bazel/config:is_windows": [ @@ -118,7 +117,7 @@ index 9648e4a..88efd41 100644 outs = [ ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), -@@ -476,7 +510,7 @@ +@@ -477,6 +511,6 @@ outs[1].path, ] + ctx.attr.args, - executable = ctx.executable.tool, @@ -155,10 +154,10 @@ index 9648e4a..88efd41 100644 suffix = suffix, target_os = select({ "@v8//bazel/config:is_macos": "mac", -diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel -index 421ebcd..52283ea 100644 ---- a/orig/v8-14.6.202.11/BUILD.bazel -+++ b/mod/v8-14.6.202.11/BUILD.bazel +diff --git a/orig/v8-14.9.207.2/BUILD.bazel b/mod/v8-14.9.207.2/BUILD.bazel +index b432f86..28c567b 100644 +--- a/orig/v8-14.9.207.2/BUILD.bazel ++++ b/mod/v8-14.9.207.2/BUILD.bazel @@ -148,6 +148,10 @@ v8_flag(name = "v8_enable_trace_maps") v8_flag(name = "v8_enable_v8_checks") @@ -187,7 +186,7 @@ index 421ebcd..52283ea 100644 "v8_enable_trace_maps": "V8_TRACE_MAPS", "v8_enable_turbofan": "V8_ENABLE_TURBOFAN", "v8_enable_v8_checks": "V8_ENABLE_CHECKS", -@@ -4098,28 +4103,14 @@ filegroup( +@@ -4125,28 +4130,14 @@ filegroup( }), ) @@ -222,7 +221,7 @@ index 421ebcd..52283ea 100644 ) filegroup( -@@ -4422,6 +4413,20 @@ genrule( +@@ -4441,6 +4432,20 @@ genrule( srcs = [ "include/js_protocol.pdl", "src/inspector/inspector_protocol_config.json", @@ -243,7 +242,7 @@ index 421ebcd..52283ea 100644 ], outs = [ "include/inspector/Debugger.h", -@@ -4443,15 +4448,19 @@ genrule( +@@ -4462,15 +4467,19 @@ genrule( "src/inspector/protocol/Schema.cpp", "src/inspector/protocol/Schema.h", ], @@ -267,7 +266,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4465,6 +4474,35 @@ filegroup( +@@ -4484,6 +4493,35 @@ filegroup( ], ) @@ -303,7 +302,7 @@ index 421ebcd..52283ea 100644 filegroup( name = "d8_files", srcs = [ -@@ -4584,16 +4602,9 @@ cc_library( +@@ -4603,16 +4641,9 @@ cc_library( ], ) @@ -323,7 +322,7 @@ index 421ebcd..52283ea 100644 ) v8_library( -@@ -4610,7 +4621,7 @@ v8_library( +@@ -4629,7 +4660,7 @@ v8_library( copts = ["-Wno-implicit-fallthrough"], icu_deps = [ ":icu/generated_torque_definitions_headers", @@ -332,7 +331,7 @@ index 421ebcd..52283ea 100644 ], icu_srcs = [ ":generated_regexp_special_case", -@@ -4625,7 +4636,7 @@ v8_library( +@@ -4644,7 +4675,7 @@ v8_library( ], deps = [ ":lib_dragonbox", @@ -341,7 +340,7 @@ index 421ebcd..52283ea 100644 ":lib_fp16", ":simdutf", ":v8_libbase", -@@ -4681,6 +4692,7 @@ alias( +@@ -4700,6 +4731,7 @@ alias( alias( name = "core_lib_icu", actual = "icu/v8", @@ -349,7 +348,7 @@ index 421ebcd..52283ea 100644 ) v8_library( -@@ -4732,7 +4744,7 @@ v8_binary( +@@ -4751,7 +4783,7 @@ v8_binary( ], deps = [ ":v8_libbase", @@ -358,7 +357,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4772,9 +4784,20 @@ v8_binary( +@@ -4791,9 +4823,20 @@ v8_binary( ":icu/generated_torque_initializers", ":icu/v8_initializers_files", ], @@ -380,10 +379,10 @@ index 421ebcd..52283ea 100644 }), noicu_deps = [":v8_libshared_noicu"], noicu_srcs = [ -diff --git a/orig/v8-14.6.202.11/bazel/BUILD.icu b/mod/v8-14.6.202.11/bazel/BUILD.icu -index 5fda2f4..9729451 100644 ---- a/orig/v8-14.6.202.11/bazel/BUILD.icu -+++ b/mod/v8-14.6.202.11/bazel/BUILD.icu +diff --git a/orig/v8-14.9.207.2/bazel/BUILD.icu b/mod/v8-14.9.207.2/bazel/BUILD.icu +index 5fda2f4..8c3c372 100644 +--- a/orig/v8-14.9.207.2/bazel/BUILD.icu ++++ b/mod/v8-14.9.207.2/bazel/BUILD.icu @@ -1,3 +1,24 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + @@ -409,7 +408,7 @@ index 5fda2f4..9729451 100644 # Copyright 2021 the V8 project authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. -@@ -16,15 +37,12 @@ +@@ -16,15 +37,12 @@ cc_library( ]), copts = select({ "@platforms//os:windows": [ @@ -427,7 +426,7 @@ index 5fda2f4..9729451 100644 data = [":icudata"], defines = [ "HAVE_DLOPEN=0", -@@ -54,6 +72,7 @@ +@@ -54,6 +72,7 @@ cc_library( "U_ICUDATAENTRY_IN_COMMON", ], tags = ["requires-rtti"], @@ -435,7 +434,7 @@ index 5fda2f4..9729451 100644 alwayslink = 1, ) -@@ -65,19 +84,16 @@ +@@ -65,19 +84,16 @@ cc_library( ]), copts = select({ "@platforms//os:windows": [ @@ -458,7 +457,7 @@ index 5fda2f4..9729451 100644 alwayslink = 1, ) -@@ -93,13 +109,10 @@ +@@ -93,13 +109,10 @@ cc_library( ]), copts = select({ "@platforms//os:windows": [ @@ -474,7 +473,7 @@ index 5fda2f4..9729451 100644 include_prefix = "third_party/icu", local_defines = [ "U_COMMON_IMPLEMENTATION", -@@ -108,6 +121,6 @@ +@@ -108,6 +121,6 @@ cc_library( deps = [ ":icui18n", ":icuuc", diff --git a/patches/v8_module_deps.patch b/patches/v8_module_deps.patch index 8f0fed9cf4..4df93f00f0 100644 --- a/patches/v8_module_deps.patch +++ b/patches/v8_module_deps.patch @@ -3,14 +3,25 @@ # Scope: upstream MODULE.bazel only; affects external repo resolution and Bazel # module wiring, not V8 source files. -diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel ---- a/orig/v8-14.6.202.11/MODULE.bazel -+++ b/mod/v8-14.6.202.11/MODULE.bazel -@@ -8,7 +8,65 @@ +diff --git a/orig/v8-14.9.207.2/MODULE.bazel b/mod/v8-14.9.207.2/MODULE.bazel +index b8bf8bd..573f463 100644 +--- a/orig/v8-14.9.207.2/MODULE.bazel ++++ b/mod/v8-14.9.207.2/MODULE.bazel +@@ -8,193 +8,67 @@ bazel_dep(name = "rules_cc", version = "0.2.0") bazel_dep(name = "rules_python", version = "1.0.0") bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "abseil-cpp", version = "20250814.0") -bazel_dep(name = "highway", version = "1.2.0") +- +-pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +-pip.parse( +- hub_name = "v8_python_deps", +- python_version = "3.11", +- requirements_lock = "//:bazel/requirements.txt", +- extra_pip_args = [ +- "--require-hashes", +- "--index-url=https://pypi.org/simple", +- ], +bazel_dep(name = "rules_license", version = "0.0.4") + +git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") @@ -23,53 +34,9 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel + sha256 = "7e0be78b8318e8bdbf6fa545d2ecb4c90f947df03f7aadc42c1967f019e63343", + strip_prefix = "highway-1.2.0", + urls = ["https://github.com/google/highway/archive/refs/tags/1.2.0.tar.gz"], -+) -+ -+git_repository( -+ name = "icu", -+ build_file = "@v8//:bazel/BUILD.icu", -+ commit = "a86a32e67b8d1384b33f8fa48c83a6079b86f8cd", -+ patch_cmds = ["find source -name BUILD.bazel | xargs rm"], -+ patch_cmds_win = ["Get-ChildItem -Path source -File -Include BUILD.bazel -Recurse | Remove-Item"], -+ remote = "https://chromium.googlesource.com/chromium/deps/icu.git", -+) -+ -+http_archive( -+ name = "fast_float", -+ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "fast_float",\n hdrs = glob(["include/fast_float/*.h"]),\n include_prefix = "third_party/fast_float/src/include",\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', -+ sha256 = "e14a33089712b681d74d94e2a11362643bd7d769ae8f7e7caefe955f57f7eacd", -+ strip_prefix = "fast_float-8.0.2", -+ urls = ["https://github.com/fastfloat/fast_float/archive/refs/tags/v8.0.2.tar.gz"], -+) -+ -+http_archive( -+ name = "simdutf", -+ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "simdutf",\n srcs = ["src/simdutf.cpp"],\n hdrs = glob(["include/**/*.h"]),\n textual_hdrs = glob(["src/**/*.h", "src/**/*.cpp"], exclude = ["src/simdutf.cpp"]),\n copts = ["-std=c++20"],\n include_prefix = "third_party/simdutf",\n includes = ["include", "src"],\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', -+ sha256 = "0180de81a1dd48a87b8c0442ffa81734f3db91a7350914107a449935124e3c6f", -+ strip_prefix = "simdutf-7.7.0", -+ urls = ["https://github.com/simdutf/simdutf/archive/refs/tags/v7.7.0.tar.gz"], -+) -+ -+git_repository( -+ name = "dragonbox", -+ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "dragonbox",\n hdrs = ["include/dragonbox/dragonbox.h"],\n include_prefix = "third_party/dragonbox/src/include",\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', -+ commit = "beeeef91cf6fef89a4d4ba5e95d47ca64ccb3a44", -+ remote = "https://chromium.googlesource.com/external/github.com/jk-jeon/dragonbox.git", -+) -+ -+git_repository( -+ name = "fp16", -+ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "fp16",\n hdrs = glob(["include/**/*.h"]),\n include_prefix = "third_party/fp16/src/include",\n includes = ["include"],\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', -+ commit = "3d2de1816307bac63c16a297e8c4dc501b4076df", -+ remote = "https://chromium.googlesource.com/external/github.com/Maratyszcza/FP16.git", -+) - - pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") - pip.parse( -@@ -22,171 +80,3 @@ ) - use_repo(pip, "v8_python_deps") - +-use_repo(pip, "v8_python_deps") +- -# Define the local LLVM toolchain repository -llvm_toolchain_repository = use_repo_rule("//bazel/toolchain:llvm_repository.bzl", "llvm_toolchain_repository") - @@ -77,8 +44,9 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - name = "llvm_toolchain", - path = "third_party/llvm-build/Release+Asserts", - config_file_content = """ +-load("@rules_cc//cc:defs.bzl", "CcToolchainConfigInfo", "cc_common") -load("@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl", "feature", "flag_group", "flag_set", "tool_path") -- + -def _impl(ctx): - tool_paths = [ - tool_path(name = "gcc", path = "bin/clang"), @@ -157,6 +125,7 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - - return cc_common.create_cc_toolchain_config_info( - ctx = ctx, +- toolchain_identifier = "local_clang", - features = features, - cxx_builtin_include_directories = [ - "{WORKSPACE_ROOT}/buildtools/third_party/libc++", @@ -169,7 +138,6 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - "{WORKSPACE_ROOT}/build/linux/debian_bullseye_amd64-sysroot/usr/include", - "{WORKSPACE_ROOT}/build/linux/debian_bullseye_amd64-sysroot/usr/local/include", - ], -- toolchain_identifier = "local_clang", - host_system_name = "local", - target_system_name = "local", - target_cpu = "k8", @@ -184,18 +152,32 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - implementation = _impl, - attrs = {}, - provides = [CcToolchainConfigInfo], --) ++git_repository( ++ name = "icu", ++ build_file = "@v8//:bazel/BUILD.icu", ++ commit = "a86a32e67b8d1384b33f8fa48c83a6079b86f8cd", ++ patch_cmds = ["find source -name BUILD.bazel | xargs rm"], ++ patch_cmds_win = ["Get-ChildItem -Path source -File -Include BUILD.bazel -Recurse | Remove-Item"], ++ remote = "https://chromium.googlesource.com/chromium/deps/icu.git", + ) -""", - build_file_content = """ +-load("@rules_cc//cc:defs.bzl", "cc_toolchain") -load(":cc_toolchain_config.bzl", "cc_toolchain_config") - -package(default_visibility = ["//visibility:public"]) -- + -filegroup( - name = "all_files", - srcs = glob(["**/*"]), --) -- ++http_archive( ++ name = "fast_float", ++ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "fast_float",\n hdrs = glob(["include/fast_float/*.h"]),\n include_prefix = "third_party/fast_float/src/include",\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', ++ sha256 = "e14a33089712b681d74d94e2a11362643bd7d769ae8f7e7caefe955f57f7eacd", ++ strip_prefix = "fast_float-8.0.2", ++ urls = ["https://github.com/fastfloat/fast_float/archive/refs/tags/v8.0.2.tar.gz"], + ) + -filegroup(name = "empty") - -cc_toolchain_config(name = "k8_toolchain_config") @@ -212,8 +194,14 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - supports_param_files = 0, - toolchain_config = ":k8_toolchain_config", - toolchain_identifier = "local_clang", --) -- ++http_archive( ++ name = "simdutf", ++ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "simdutf",\n srcs = ["src/simdutf.cpp"],\n hdrs = glob(["include/**/*.h"]),\n textual_hdrs = glob(["src/**/*.h", "src/**/*.cpp"], exclude = ["src/simdutf.cpp"]),\n copts = ["-std=c++20"],\n include_prefix = "third_party/simdutf",\n includes = ["include", "src"],\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', ++ sha256 = "0180de81a1dd48a87b8c0442ffa81734f3db91a7350914107a449935124e3c6f", ++ strip_prefix = "simdutf-7.7.0", ++ urls = ["https://github.com/simdutf/simdutf/archive/refs/tags/v7.7.0.tar.gz"], + ) + -toolchain( - name = "cc_toolchain_k8", - exec_compatible_with = [ @@ -228,8 +216,13 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - toolchain_type = "@bazel_tools//tools/cpp:toolchain_type", -) -""", --) -- ++git_repository( ++ name = "dragonbox", ++ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "dragonbox",\n hdrs = ["include/dragonbox/dragonbox.h"],\n include_prefix = "third_party/dragonbox/src/include",\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', ++ commit = "beeeef91cf6fef89a4d4ba5e95d47ca64ccb3a44", ++ remote = "https://chromium.googlesource.com/external/github.com/jk-jeon/dragonbox.git", + ) + -register_toolchains("@llvm_toolchain//:cc_toolchain_k8") - -# Define local repository for libc++ from third_party sources @@ -237,11 +230,33 @@ diff --git a/orig/v8-14.6.202.11/MODULE.bazel b/mod/v8-14.6.202.11/MODULE.bazel - -libcxx_repository( - name = "libcxx", --) -diff --git a/orig/v8-14.6.202.11/bazel/highway.patch b/mod/v8-14.6.202.11/bazel/highway.patch ++git_repository( ++ name = "fp16", ++ build_file_content = 'load("@rules_cc//cc:defs.bzl", "cc_library")\n\ncc_library(\n name = "fp16",\n hdrs = glob(["include/**/*.h"]),\n include_prefix = "third_party/fp16/src/include",\n includes = ["include"],\n strip_include_prefix = "include",\n visibility = ["//visibility:public"],\n)\n', ++ commit = "3d2de1816307bac63c16a297e8c4dc501b4076df", ++ remote = "https://chromium.googlesource.com/external/github.com/Maratyszcza/FP16.git", + ) + +-single_version_override( +- module_name = "highway", +- patches = ["//bazel/patches:highway_rules_cc.patch"], +- patch_strip = 1, ++pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") ++pip.parse( ++ hub_name = "v8_python_deps", ++ python_version = "3.11", ++ requirements_lock = "//:bazel/requirements.txt", ++ extra_pip_args = [ ++ "--require-hashes", ++ "--index-url=https://pypi.org/simple", ++ ], + ) ++use_repo(pip, "v8_python_deps") +diff --git a/mod/v8-14.9.207.2/bazel/highway.patch b/mod/v8-14.9.207.2/bazel/highway.patch new file mode 100644 +index 0000000..5a13dd0 --- /dev/null -+++ b/mod/v8-14.6.202.11/bazel/highway.patch ++++ b/mod/v8-14.9.207.2/bazel/highway.patch @@ -0,0 +1,25 @@ +diff --git a/BUILD b/BUILD +--- a/BUILD @@ -249,22 +264,22 @@ new file mode 100644 +@@ -2,7 +2,7 @@ + load("@bazel_skylib//lib:selects.bzl", "selects") + load("@rules_license//rules:license.bzl", "license") -+ ++ +-load("@rules_cc//cc:defs.bzl", "cc_test") ++load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") + # Placeholder#2 for Guitar, do not remove -+ ++ + package( +@@ -134,11 +134,7 @@ COPTS = select({ + ], + }) -+ ++ +-DEFINES = select({ +- ":compiler_msvc": ["HWY_SHARED_DEFINE"], +- ":compiler_clangcl": ["HWY_SHARED_DEFINE"], +- "//conditions:default": [], +-}) ++DEFINES = [] -+ ++ + # Unused on Bazel builds, where this is not defined/known; Copybara replaces + # usages with an empty list. diff --git a/patches/v8_source_portability.patch b/patches/v8_source_portability.patch index d480e11c1a..0e042c76d9 100644 --- a/patches/v8_source_portability.patch +++ b/patches/v8_source_portability.patch @@ -2,10 +2,10 @@ # Scope: minimal source-level portability fixes only, such as libexecinfo guards, # weak glibc symbol handling, and warning annotations; no dependency # include-path rewrites or intentional V8 feature changes. -diff --git a/orig/v8-14.6.202.11/src/base/bits.h b/mod/v8-14.6.202.11/src/base/bits.h +diff --git a/orig/v8-14.9.207.2/src/base/bits.h b/mod/v8-14.9.207.2/src/base/bits.h index 179a10f..4791e96 100644 ---- a/orig/v8-14.6.202.11/src/base/bits.h -+++ b/mod/v8-14.6.202.11/src/base/bits.h +--- a/orig/v8-14.9.207.2/src/base/bits.h ++++ b/mod/v8-14.9.207.2/src/base/bits.h @@ -270,11 +270,17 @@ inline constexpr uint32_t RoundDownToPowerOfTwo32(uint32_t value) { } @@ -24,10 +24,10 @@ index 179a10f..4791e96 100644 inline constexpr uint32_t RotateLeft32(uint32_t value, uint32_t shift) { return (value << shift) | (value >> ((32 - shift) & 31)); } -diff --git a/orig/v8-14.6.202.11/src/base/debug/stack_trace_posix.cc b/mod/v8-14.6.202.11/src/base/debug/stack_trace_posix.cc +diff --git a/orig/v8-14.9.207.2/src/base/debug/stack_trace_posix.cc b/mod/v8-14.9.207.2/src/base/debug/stack_trace_posix.cc index 6176ed4..a02043d 100644 ---- a/orig/v8-14.6.202.11/src/base/debug/stack_trace_posix.cc -+++ b/mod/v8-14.6.202.11/src/base/debug/stack_trace_posix.cc +--- a/orig/v8-14.9.207.2/src/base/debug/stack_trace_posix.cc ++++ b/mod/v8-14.9.207.2/src/base/debug/stack_trace_posix.cc @@ -64,6 +64,7 @@ namespace { volatile sig_atomic_t in_signal_handler = 0; bool dump_stack_in_signal_handler = true; @@ -44,10 +44,10 @@ index 6176ed4..a02043d 100644 // Demangles C++ symbols in the given text. Example: // // "out/Debug/base_unittests(_ZN10StackTraceC1Ev+0x20) [0x817778c]" -diff --git a/orig/v8-14.6.202.11/src/base/export-template.h b/mod/v8-14.6.202.11/src/base/export-template.h +diff --git a/orig/v8-14.9.207.2/src/base/export-template.h b/mod/v8-14.9.207.2/src/base/export-template.h index 861cfe4..1e73954 100644 ---- a/orig/v8-14.6.202.11/src/base/export-template.h -+++ b/mod/v8-14.6.202.11/src/base/export-template.h +--- a/orig/v8-14.9.207.2/src/base/export-template.h ++++ b/mod/v8-14.9.207.2/src/base/export-template.h @@ -153,8 +153,10 @@ EXPORT_TEMPLATE_TEST(DEFAULT, ); @@ -59,10 +59,10 @@ index 861cfe4..1e73954 100644 #undef EXPORT_TEMPLATE_TEST #undef EXPORT_TEMPLATE_TEST_DEFAULT_DEFAULT -diff --git a/orig/v8-14.6.202.11/src/base/platform/platform-posix.cc b/mod/v8-14.6.202.11/src/base/platform/platform-posix.cc -index 420df0b..6f47969 100644 ---- a/orig/v8-14.6.202.11/src/base/platform/platform-posix.cc -+++ b/mod/v8-14.6.202.11/src/base/platform/platform-posix.cc +diff --git a/orig/v8-14.9.207.2/src/base/platform/platform-posix.cc b/mod/v8-14.9.207.2/src/base/platform/platform-posix.cc +index dfb30b6..6ce12a5 100644 +--- a/orig/v8-14.9.207.2/src/base/platform/platform-posix.cc ++++ b/mod/v8-14.9.207.2/src/base/platform/platform-posix.cc @@ -95,7 +95,7 @@ extern int madvise(caddr_t, size_t, int); #endif @@ -82,10 +82,10 @@ index 420df0b..6f47969 100644 DCHECK(MainThreadIsCurrentThread()); return __libc_stack_end; } -diff --git a/orig/v8-14.6.202.11/src/base/platform/platform-win32.cc b/mod/v8-14.6.202.11/src/base/platform/platform-win32.cc -index f5d9ddc..1c08b0f 100644 ---- a/orig/v8-14.6.202.11/src/base/platform/platform-win32.cc -+++ b/mod/v8-14.6.202.11/src/base/platform/platform-win32.cc +diff --git a/orig/v8-14.9.207.2/src/base/platform/platform-win32.cc b/mod/v8-14.9.207.2/src/base/platform/platform-win32.cc +index 11f9772..854a682 100644 +--- a/orig/v8-14.9.207.2/src/base/platform/platform-win32.cc ++++ b/mod/v8-14.9.207.2/src/base/platform/platform-win32.cc @@ -20,7 +20,11 @@ #include @@ -98,7 +98,7 @@ index f5d9ddc..1c08b0f 100644 #include // For SymLoadModule64 and al. #include // For _msize() #include // For timeGetTime(). -@@ -69,9 +69,7 @@ static_assert(offsetof(V8_CRITICAL_SECTION, SpinCount) == +@@ -69,9 +73,7 @@ static_assert(offsetof(V8_CRITICAL_SECTION, SpinCount) == // Extra functions for MinGW. Most of these are the _s functions which are in // the Microsoft Visual Studio C++ CRT. #ifdef __MINGW32__ @@ -109,7 +109,7 @@ index f5d9ddc..1c08b0f 100644 #define _TRUNCATE 0 #define STRUNCATE 80 -@@ -81,9 +79,6 @@ inline void MemoryFence() { +@@ -81,9 +83,6 @@ inline void MemoryFence() { __asm__ __volatile__("xchgl %%eax,%0 ":"=r" (barrier)); } @@ -119,7 +119,7 @@ index f5d9ddc..1c08b0f 100644 int localtime_s(tm* out_tm, const time_t* time) { tm* posix_local_time_struct = localtime_r(time, out_tm); if (posix_local_time_struct == nullptr) return 1; -@@ -134,6 +129,8 @@ int strncpy_s(char* dest, size_t dest_size, const char* source, size_t count) { +@@ -134,6 +133,8 @@ int strncpy_s(char* dest, size_t dest_size, const char* source, size_t count) { return 0; } @@ -128,7 +128,7 @@ index f5d9ddc..1c08b0f 100644 #endif // __MINGW32__ namespace v8 { -@@ -743,8 +740,10 @@ void OS::StrNCpy(char* dest, int length, const char* src, size_t n) { +@@ -743,8 +744,10 @@ void OS::StrNCpy(char* dest, int length, const char* src, size_t n) { } @@ -139,15 +139,15 @@ index f5d9ddc..1c08b0f 100644 DEFINE_LAZY_LEAKY_OBJECT_GETTER(RandomNumberGenerator, GetPlatformRandomNumberGenerator) -@@ -1894,3 +1893,4 @@ Stack::StackSlot Stack::GetCurrentStackPosition() { +@@ -1943,3 +1946,4 @@ Stack::StackSlot Stack::GetCurrentStackPosition() { } // namespace base } // namespace v8 + -diff --git a/orig/v8-14.6.202.11/src/base/platform/time.cc b/mod/v8-14.6.202.11/src/base/platform/time.cc -index 8390288..cba6cd5 100644 ---- a/orig/v8-14.6.202.11/src/base/platform/time.cc -+++ b/mod/v8-14.6.202.11/src/base/platform/time.cc +diff --git a/orig/v8-14.9.207.2/src/base/platform/time.cc b/mod/v8-14.9.207.2/src/base/platform/time.cc +index 8c824e3..f8144d0 100644 +--- a/orig/v8-14.9.207.2/src/base/platform/time.cc ++++ b/mod/v8-14.9.207.2/src/base/platform/time.cc @@ -782,12 +782,12 @@ bool ThreadTicks::IsSupported() { #elif defined(__PASE__) // Thread CPU time accounting is unavailable in PASE @@ -179,10 +179,10 @@ index 8390288..cba6cd5 100644 #else UNREACHABLE(); #endif -diff --git a/orig/v8-14.6.202.11/src/heap/base/asm/x64/push_registers_masm.asm b/mod/v8-14.6.202.11/src/heap/base/asm/x64/push_registers_masm.asm -index d0d0563..72e230b 100644 ---- a/orig/v8-14.6.202.11/src/heap/base/asm/x64/push_registers_masm.asm -+++ b/mod/v8-14.6.202.11/src/heap/base/asm/x64/push_registers_masm.asm +diff --git a/orig/v8-14.9.207.2/src/heap/base/asm/x64/push_registers_masm.asm b/mod/v8-14.9.207.2/src/heap/base/asm/x64/push_registers_masm.asm +index d0d0563..f3f5952 100644 +--- a/orig/v8-14.9.207.2/src/heap/base/asm/x64/push_registers_masm.asm ++++ b/mod/v8-14.9.207.2/src/heap/base/asm/x64/push_registers_masm.asm @@ -1,70 +1,47 @@ -;; Copyright 2020 the V8 project authors. All rights reserved. -;; Use of this source code is governed by a BSD-style license that can be @@ -301,10 +301,10 @@ index d0d0563..72e230b 100644 + pop %rbp + ret +.seh_endproc -diff --git a/orig/v8-14.6.202.11/src/libplatform/default-thread-isolated-allocator.cc b/mod/v8-14.6.202.11/src/libplatform/default-thread-isolated-allocator.cc +diff --git a/orig/v8-14.9.207.2/src/libplatform/default-thread-isolated-allocator.cc b/mod/v8-14.9.207.2/src/libplatform/default-thread-isolated-allocator.cc index bda0e43..b44f1d9 100644 ---- a/orig/v8-14.6.202.11/src/libplatform/default-thread-isolated-allocator.cc -+++ b/mod/v8-14.6.202.11/src/libplatform/default-thread-isolated-allocator.cc +--- a/orig/v8-14.9.207.2/src/libplatform/default-thread-isolated-allocator.cc ++++ b/mod/v8-14.9.207.2/src/libplatform/default-thread-isolated-allocator.cc @@ -23,7 +23,7 @@ extern int pkey_free(int pkey) V8_WEAK; namespace { @@ -314,11 +314,11 @@ index bda0e43..b44f1d9 100644 // PKU was broken on Linux kernels before 5.13 (see // https://lore.kernel.org/all/20210623121456.399107624@linutronix.de/). // A fix is also included in the 5.4.182 and 5.10.103 versions ("x86/fpu: -diff --git a/orig/v8-14.6.202.11/src/profiler/heap-snapshot-generator.cc b/mod/v8-14.6.202.11/src/profiler/heap-snapshot-generator.cc -index 27e748f..cbf886c 100644 ---- a/orig/v8-14.6.202.11/src/profiler/heap-snapshot-generator.cc -+++ b/mod/v8-14.6.202.11/src/profiler/heap-snapshot-generator.cc -@@ -429,11 +429,11 @@ HeapSnapshot::HeapSnapshot(HeapProfiler* profiler, +diff --git a/orig/v8-14.9.207.2/src/profiler/heap-snapshot-generator.cc b/mod/v8-14.9.207.2/src/profiler/heap-snapshot-generator.cc +index 8edad48..d4b2721 100644 +--- a/orig/v8-14.9.207.2/src/profiler/heap-snapshot-generator.cc ++++ b/mod/v8-14.9.207.2/src/profiler/heap-snapshot-generator.cc +@@ -478,11 +478,11 @@ HeapSnapshot::HeapSnapshot(HeapProfiler* profiler, static_assert(kSystemPointerSize != 4 || sizeof(HeapGraphEdge) == 12); static_assert(kSystemPointerSize != 8 || sizeof(HeapGraphEdge) == 24); static_assert(kSystemPointerSize != 4 || sizeof(HeapEntry) == 32); @@ -332,7 +332,7 @@ index 27e748f..cbf886c 100644 memset(&gc_subroot_entries_, 0, sizeof(gc_subroot_entries_)); } -@@ -3878,3 +3878,4 @@ void HeapSnapshotJSONSerializer::SerializeLocations() { +@@ -4015,3 +4015,4 @@ void HeapSnapshotJSONSerializer::SerializeLocations() { } } // namespace v8::internal diff --git a/third_party/v8/BUILD.bazel b/third_party/v8/BUILD.bazel index 459d935854..425d81ea85 100644 --- a/third_party/v8/BUILD.bazel +++ b/third_party/v8/BUILD.bazel @@ -30,26 +30,26 @@ config_setting( ) alias( - name = "v8_147_4_0_x86_64_pc_windows_msvc", - actual = "@rusty_v8_147_4_0_x86_64_pc_windows_msvc_archive//file", + name = "v8_149_2_0_x86_64_pc_windows_msvc", + actual = "@rusty_v8_149_2_0_x86_64_pc_windows_msvc_archive//file", ) alias( - name = "v8_147_4_0_aarch64_pc_windows_msvc", - actual = "@rusty_v8_147_4_0_aarch64_pc_windows_msvc_archive//file", + name = "v8_149_2_0_aarch64_pc_windows_msvc", + actual = "@rusty_v8_149_2_0_aarch64_pc_windows_msvc_archive//file", ) alias( - name = "v8_147_4_0_aarch64_pc_windows_gnullvm", + name = "v8_149_2_0_aarch64_pc_windows_gnullvm", # `rusty_v8` only ships prebuilt Windows archives for MSVC. Build the # GNU-flavored archive in-tree so windows-gnullvm consumers can link # against a matching ABI instead of trying to reuse the MSVC release. - actual = ":v8_147_4_0_aarch64_pc_windows_gnullvm_bazel", + actual = ":v8_149_2_0_aarch64_pc_windows_gnullvm_bazel", ) alias( - name = "v8_147_4_0_x86_64_pc_windows_gnullvm", - actual = ":v8_147_4_0_x86_64_pc_windows_gnullvm_bazel", + name = "v8_149_2_0_x86_64_pc_windows_gnullvm", + actual = ":v8_149_2_0_x86_64_pc_windows_gnullvm_bazel", ) filegroup( @@ -73,50 +73,50 @@ filegroup( ) alias( - name = "src_binding_release_x86_64_pc_windows_gnullvm_147_4_0_release", + name = "src_binding_release_x86_64_pc_windows_gnullvm_149_2_0_release", # `rusty_v8` does not publish a Windows GNU binding file. The generated # binding only describes this V8 release's C++ API surface, so reuse the # Linux release binding while the windows-gnullvm archive build is still # experimental. - actual = ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + actual = ":src_binding_release_x86_64_unknown_linux_gnu_149_2_0_release", ) alias( - name = "src_binding_release_aarch64_pc_windows_gnullvm_147_4_0_release", - actual = ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", + name = "src_binding_release_aarch64_pc_windows_gnullvm_149_2_0_release", + actual = ":src_binding_release_aarch64_unknown_linux_gnu_149_2_0_release", ) alias( name = "rusty_v8_archive_for_target", actual = select({ - "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":v8_147_4_0_aarch64_apple_darwin_bazel", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":v8_147_4_0_aarch64_pc_windows_gnullvm", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":v8_147_4_0_aarch64_pc_windows_msvc", - "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":v8_147_4_0_aarch64_unknown_linux_gnu_bazel", - ":platform_aarch64_unknown_linux_musl": ":v8_147_4_0_aarch64_unknown_linux_musl_release_base", - "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":v8_147_4_0_x86_64_apple_darwin_bazel", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":v8_147_4_0_x86_64_pc_windows_gnullvm", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":v8_147_4_0_x86_64_pc_windows_msvc", - "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", - ":platform_x86_64_unknown_linux_musl": ":v8_147_4_0_x86_64_unknown_linux_musl_release", - "//conditions:default": ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", + "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":v8_149_2_0_aarch64_apple_darwin_bazel", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":v8_149_2_0_aarch64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":v8_149_2_0_aarch64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":v8_149_2_0_aarch64_unknown_linux_gnu_bazel", + ":platform_aarch64_unknown_linux_musl": ":v8_149_2_0_aarch64_unknown_linux_musl_release_base", + "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":v8_149_2_0_x86_64_apple_darwin_bazel", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":v8_149_2_0_x86_64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":v8_149_2_0_x86_64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":v8_149_2_0_x86_64_unknown_linux_gnu_bazel", + ":platform_x86_64_unknown_linux_musl": ":v8_149_2_0_x86_64_unknown_linux_musl_release", + "//conditions:default": ":v8_149_2_0_x86_64_unknown_linux_gnu_bazel", }), ) alias( name = "rusty_v8_binding_for_target", actual = select({ - "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":src_binding_release_aarch64_apple_darwin_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":src_binding_release_aarch64_pc_windows_gnullvm_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":src_binding_release_aarch64_pc_windows_msvc_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", - ":platform_aarch64_unknown_linux_musl": ":src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":src_binding_release_x86_64_apple_darwin_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":src_binding_release_x86_64_pc_windows_gnullvm_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":src_binding_release_x86_64_pc_windows_msvc_147_4_0_release", - "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", - ":platform_x86_64_unknown_linux_musl": ":src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", - "//conditions:default": ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":src_binding_release_aarch64_apple_darwin_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":src_binding_release_aarch64_pc_windows_gnullvm_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":src_binding_release_aarch64_pc_windows_msvc_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":src_binding_release_aarch64_unknown_linux_gnu_149_2_0_release", + ":platform_aarch64_unknown_linux_musl": ":src_binding_release_aarch64_unknown_linux_musl_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":src_binding_release_x86_64_apple_darwin_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":src_binding_release_x86_64_pc_windows_gnullvm_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":src_binding_release_x86_64_pc_windows_msvc_149_2_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":src_binding_release_x86_64_unknown_linux_gnu_149_2_0_release", + ":platform_x86_64_unknown_linux_musl": ":src_binding_release_x86_64_unknown_linux_musl_149_2_0_release", + "//conditions:default": ":src_binding_release_x86_64_unknown_linux_gnu_149_2_0_release", }), ) @@ -202,51 +202,51 @@ cc_library( ) genrule( - name = "binding_cc_147_4_0", - srcs = ["@v8_crate_147_4_0//:binding_cc"], - outs = ["binding_147_4_0.cc"], + name = "binding_cc_149_2_0", + srcs = ["@v8_crate_149_2_0//:binding_cc"], + outs = ["binding_149_2_0.cc"], cmd = " ".join([ "sed", "-e '/#include \"v8\\/src\\/flags\\/flags.h\"/d'", "-e 's|\"v8/src/libplatform/default-platform.h\"|\"src/libplatform/default-platform.h\"|'", - "-e 's|#include \"support.h\"|#include \"support_147_4_0.h\"|'", + "-e 's|#include \"support.h\"|#include \"support_149_2_0.h\"|'", "-e 's| namespace i = v8::internal;| (void)usage;|'", "-e '/using HelpOptions = i::FlagList::HelpOptions;/d'", "-e '/HelpOptions help_options = HelpOptions(HelpOptions::kExit, usage);/d'", "-e 's| i::FlagList::SetFlagsFromCommandLine(argc, argv, true, help_options);| v8::V8::SetFlagsFromCommandLine(argc, argv, true);|'", - "$(location @v8_crate_147_4_0//:binding_cc)", + "$(location @v8_crate_149_2_0//:binding_cc)", ">", '"$@"', ]), ) genrule( - name = "crdtp_binding_cc_147_4_0", - srcs = ["@v8_crate_147_4_0//:crdtp_binding_cc"], - outs = ["crdtp_binding_147_4_0.cc"], + name = "crdtp_binding_cc_149_2_0", + srcs = ["@v8_crate_149_2_0//:crdtp_binding_cc"], + outs = ["crdtp_binding_149_2_0.cc"], cmd = " ".join([ "sed", - "-e 's|#include \"support.h\"|#include \"support_147_4_0.h\"|'", + "-e 's|#include \"support.h\"|#include \"support_149_2_0.h\"|'", "-e 's|\"v8/third_party/inspector_protocol/|\"third_party/inspector_protocol/|g'", - "$(location @v8_crate_147_4_0//:crdtp_binding_cc)", + "$(location @v8_crate_149_2_0//:crdtp_binding_cc)", ">", '"$@"', ]), ) copy_file( - name = "support_h_147_4_0", - src = "@v8_crate_147_4_0//:support_h", - out = "support_147_4_0.h", + name = "support_h_149_2_0", + src = "@v8_crate_149_2_0//:support_h", + out = "support_149_2_0.h", ) cc_library( - name = "v8_147_4_0_binding", + name = "v8_149_2_0_binding", srcs = [ - ":binding_cc_147_4_0", - ":crdtp_binding_cc_147_4_0", + ":binding_cc_149_2_0", + ":crdtp_binding_cc_149_2_0", ], - hdrs = [":support_h_147_4_0"], + hdrs = [":support_h_149_2_0"], copts = V8_COPTS + V8_CUSTOM_LIBCXX_COPTS, deps = [ "@v8//:core_lib_icu", @@ -343,53 +343,53 @@ filegroup( ) cc_static_library( - name = "v8_147_4_0_aarch64_apple_darwin_bazel", + name = "v8_149_2_0_aarch64_apple_darwin_bazel", deps = [ ":rusty_v8_custom_libcxx_runtime", - ":v8_147_4_0_binding", + ":v8_149_2_0_binding", ], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_147_4_0_aarch64_unknown_linux_gnu_bazel", + name = "v8_149_2_0_aarch64_unknown_linux_gnu_bazel", deps = [ ":rusty_v8_custom_libcxx_runtime", - ":v8_147_4_0_binding", + ":v8_149_2_0_binding", ], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_147_4_0_aarch64_pc_windows_gnullvm_bazel", - deps = [":v8_147_4_0_binding"], + name = "v8_149_2_0_aarch64_pc_windows_gnullvm_bazel", + deps = [":v8_149_2_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_147_4_0_aarch64_unknown_linux_musl_release_base", + name = "v8_149_2_0_aarch64_unknown_linux_musl_release_base", deps = [ ":rusty_v8_custom_libcxx_runtime", - ":v8_147_4_0_binding", + ":v8_149_2_0_binding", ], features = V8_STATIC_LIBRARY_FEATURES, ) genrule( - name = "v8_147_4_0_aarch64_unknown_linux_musl_release", + name = "v8_149_2_0_aarch64_unknown_linux_musl_release", srcs = [ - ":v8_147_4_0_aarch64_unknown_linux_musl_release_base", + ":v8_149_2_0_aarch64_unknown_linux_musl_release_base", "@llvm//runtimes/compiler-rt:clang_rt.builtins.static", ], tools = [ "@llvm//tools:llvm-ar", "@llvm//tools:llvm-ranlib", ], - outs = ["libv8_147_4_0_aarch64_unknown_linux_musl.a"], + outs = ["libv8_149_2_0_aarch64_unknown_linux_musl.a"], cmd = """ cat > "$(@D)/merge.mri" <<'EOF' create $@ -addlib $(location :v8_147_4_0_aarch64_unknown_linux_musl_release_base) +addlib $(location :v8_149_2_0_aarch64_unknown_linux_musl_release_base) addlib $(location @llvm//runtimes/compiler-rt:clang_rt.builtins.static) save end @@ -400,186 +400,186 @@ EOF ) cc_static_library( - name = "v8_147_4_0_x86_64_apple_darwin_bazel", + name = "v8_149_2_0_x86_64_apple_darwin_bazel", deps = [ ":rusty_v8_custom_libcxx_runtime", - ":v8_147_4_0_binding", + ":v8_149_2_0_binding", ], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_147_4_0_x86_64_unknown_linux_gnu_bazel", + name = "v8_149_2_0_x86_64_unknown_linux_gnu_bazel", deps = [ ":rusty_v8_custom_libcxx_runtime", - ":v8_147_4_0_binding", + ":v8_149_2_0_binding", ], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_147_4_0_x86_64_pc_windows_gnullvm_bazel", - deps = [":v8_147_4_0_binding"], + name = "v8_149_2_0_x86_64_pc_windows_gnullvm_bazel", + deps = [":v8_149_2_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_147_4_0_x86_64_unknown_linux_musl_release", + name = "v8_149_2_0_x86_64_unknown_linux_musl_release", deps = [ ":rusty_v8_custom_libcxx_runtime", - ":v8_147_4_0_binding", + ":v8_149_2_0_binding", ], features = V8_STATIC_LIBRARY_FEATURES, ) filegroup( - name = "src_binding_release_aarch64_apple_darwin_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_apple_darwin"], + name = "src_binding_release_aarch64_apple_darwin_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_aarch64_apple_darwin"], ) filegroup( - name = "src_binding_release_x86_64_apple_darwin_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_apple_darwin"], + name = "src_binding_release_x86_64_apple_darwin_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_x86_64_apple_darwin"], ) filegroup( - name = "src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], + name = "src_binding_release_aarch64_unknown_linux_gnu_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_aarch64_unknown_linux_gnu"], ) filegroup( - name = "src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], + name = "src_binding_release_x86_64_unknown_linux_gnu_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_x86_64_unknown_linux_gnu"], ) filegroup( - name = "src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], + name = "src_binding_release_aarch64_unknown_linux_musl_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_aarch64_unknown_linux_gnu"], ) filegroup( - name = "src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], + name = "src_binding_release_x86_64_unknown_linux_musl_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_x86_64_unknown_linux_gnu"], ) filegroup( - name = "src_binding_release_aarch64_pc_windows_msvc_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_pc_windows_msvc"], + name = "src_binding_release_aarch64_pc_windows_msvc_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_aarch64_pc_windows_msvc"], ) filegroup( - name = "src_binding_release_x86_64_pc_windows_msvc_147_4_0_release", - srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_pc_windows_msvc"], + name = "src_binding_release_x86_64_pc_windows_msvc_149_2_0_release", + srcs = ["@v8_crate_149_2_0//:src_binding_release_x86_64_pc_windows_msvc"], ) filegroup( name = "rusty_v8_release_pair_x86_64_apple_darwin", srcs = [ - ":v8_147_4_0_x86_64_apple_darwin_bazel", - ":src_binding_release_x86_64_apple_darwin_147_4_0_release", + ":v8_149_2_0_x86_64_apple_darwin_bazel", + ":src_binding_release_x86_64_apple_darwin_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_apple_darwin", srcs = [ - ":v8_147_4_0_aarch64_apple_darwin_bazel", - ":src_binding_release_aarch64_apple_darwin_147_4_0_release", + ":v8_149_2_0_aarch64_apple_darwin_bazel", + ":src_binding_release_aarch64_apple_darwin_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_x86_64_unknown_linux_gnu", srcs = [ - ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", - ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + ":v8_149_2_0_x86_64_unknown_linux_gnu_bazel", + ":src_binding_release_x86_64_unknown_linux_gnu_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_unknown_linux_gnu", srcs = [ - ":v8_147_4_0_aarch64_unknown_linux_gnu_bazel", - ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", + ":v8_149_2_0_aarch64_unknown_linux_gnu_bazel", + ":src_binding_release_aarch64_unknown_linux_gnu_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_x86_64_unknown_linux_musl", srcs = [ - ":v8_147_4_0_x86_64_unknown_linux_musl_release", - ":src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", + ":v8_149_2_0_x86_64_unknown_linux_musl_release", + ":src_binding_release_x86_64_unknown_linux_musl_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_unknown_linux_musl", srcs = [ - ":v8_147_4_0_aarch64_unknown_linux_musl_release", - ":src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", + ":v8_149_2_0_aarch64_unknown_linux_musl_release", + ":src_binding_release_aarch64_unknown_linux_musl_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_x86_64_pc_windows_msvc", srcs = [ - ":v8_147_4_0_x86_64_pc_windows_msvc", - ":src_binding_release_x86_64_pc_windows_msvc_147_4_0_release", + ":v8_149_2_0_x86_64_pc_windows_msvc", + ":src_binding_release_x86_64_pc_windows_msvc_149_2_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_pc_windows_msvc", srcs = [ - ":v8_147_4_0_aarch64_pc_windows_msvc", - ":src_binding_release_aarch64_pc_windows_msvc_147_4_0_release", + ":v8_149_2_0_aarch64_pc_windows_msvc", + ":src_binding_release_aarch64_pc_windows_msvc_149_2_0_release", ], ) filegroup( name = "rusty_v8_sandbox_release_pair_x86_64_apple_darwin", srcs = [ - ":v8_147_4_0_x86_64_apple_darwin_bazel", - ":src_binding_release_x86_64_apple_darwin_147_4_0_release", + ":v8_149_2_0_x86_64_apple_darwin_bazel", + ":src_binding_release_x86_64_apple_darwin_149_2_0_release", ], ) filegroup( name = "rusty_v8_sandbox_release_pair_aarch64_apple_darwin", srcs = [ - ":v8_147_4_0_aarch64_apple_darwin_bazel", - ":src_binding_release_aarch64_apple_darwin_147_4_0_release", + ":v8_149_2_0_aarch64_apple_darwin_bazel", + ":src_binding_release_aarch64_apple_darwin_149_2_0_release", ], ) filegroup( name = "rusty_v8_sandbox_release_pair_x86_64_unknown_linux_gnu", srcs = [ - ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", - ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + ":v8_149_2_0_x86_64_unknown_linux_gnu_bazel", + ":src_binding_release_x86_64_unknown_linux_gnu_149_2_0_release", ], ) filegroup( name = "rusty_v8_sandbox_release_pair_aarch64_unknown_linux_gnu", srcs = [ - ":v8_147_4_0_aarch64_unknown_linux_gnu_bazel", - ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", + ":v8_149_2_0_aarch64_unknown_linux_gnu_bazel", + ":src_binding_release_aarch64_unknown_linux_gnu_149_2_0_release", ], ) filegroup( name = "rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", srcs = [ - ":v8_147_4_0_x86_64_unknown_linux_musl_release", - ":src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", + ":v8_149_2_0_x86_64_unknown_linux_musl_release", + ":src_binding_release_x86_64_unknown_linux_musl_149_2_0_release", ], ) filegroup( name = "rusty_v8_sandbox_release_pair_aarch64_unknown_linux_musl", srcs = [ - ":v8_147_4_0_aarch64_unknown_linux_musl_release", - ":src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", + ":v8_149_2_0_aarch64_unknown_linux_musl_release", + ":src_binding_release_aarch64_unknown_linux_musl_149_2_0_release", ], ) diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 8b512ecb19..4e1c022b98 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -5,23 +5,21 @@ Bazel consumer builds use: - upstream `denoland/rusty_v8` release archives on Windows MSVC - source-built V8 archives on Darwin, GNU Linux, musl Linux, and Windows GNU -- `openai/codex` release assets for published musl release pairs -Cargo builds still use prebuilt `rusty_v8` archives by default. Only Bazel -overrides `RUSTY_V8_ARCHIVE`/`RUSTY_V8_SRC_BINDING_PATH` in `MODULE.bazel` to -select source-built local archives for its consumer builds. +Local Cargo builds still use upstream prebuilt `rusty_v8` archives by default. +Selected Cargo CI, release, and package builds override +`RUSTY_V8_ARCHIVE`/`RUSTY_V8_SRC_BINDING_PATH` with Codex release assets. Bazel +sets those variables independently in `MODULE.bazel` to select source-built +local archives and bindings for its consumer builds. -Source-built Bazel V8 artifacts enable V8's in-process sandbox by default, and -the Bazel `v8` crate feature selection tracks those targets. A full consumer -rollout still needs matching sandbox-enabled archives for every non-source-built -target. Until that artifact migration lands, the rusty_v8 publishing workflows -use `--config=v8-release-compat` to preserve the current non-sandboxed release -artifact contract. +The Bazel `v8` crate feature selection enables V8's in-process sandbox for +Darwin, Linux, and Windows GNU. Windows MSVC remains on upstream non-sandboxed +prebuilts. Current pinned versions: -- Rust crate: `v8 = =147.4.0` -- Embedded upstream V8 source for Bazel-produced release builds: `14.7.173.20` +- Rust crate: `v8 = =149.2.0` +- Embedded upstream V8 source for Bazel-produced release builds: `14.9.207.2` ## Updating to a new `v8` release @@ -66,6 +64,7 @@ current assets on the same tag, with the Rust crate's sandbox feature suffix in their raw names: - `librusty_v8_ptrcomp_sandbox_release_.a.gz` +- `rusty_v8_ptrcomp_sandbox_release_.lib.gz` on Windows MSVC - `src_binding_ptrcomp_sandbox_release_.rs` The dedicated publishing workflow is `.github/workflows/rusty-v8-release.yml`. @@ -87,13 +86,20 @@ The same run also builds the matching sandbox pair targets: - `//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl` - `//third_party/v8:rusty_v8_sandbox_release_pair_aarch64_unknown_linux_musl` +The workflow also builds sandbox-enabled +`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` archive/binding pairs +from upstream `rusty_v8` source. Those ABI-specific outputs cannot be produced +by Codex's Bazel Windows GNU toolchain. + The Bazel graph pins the same libc++, libc++abi, and llvm-libc source revisions -used by `rusty_v8 v147.4.0`, compiles published artifact targets with +used by `rusty_v8 v149.2.0`, compiles published artifact targets with `--config=rusty-v8-upstream-libcxx`, and folds the matching runtime objects into -the final static archive so Cargo consumers can link it with the `v8` crate's -default `use_custom_libcxx` feature. The config keeps the object files and the -bundled runtime on Chromium's `std::__Cr` ABI namespace instead of mixing those -objects with the toolchain libc++ default namespace. +the final static archive so consumers can link it with the `v8` crate's default +`use_custom_libcxx` feature. The config keeps the object files and the bundled +runtime on Chromium's `std::__Cr` ABI namespace instead of mixing those objects +with the toolchain libc++ default namespace. Bazel consumers use these +source-built targets directly; Cargo release and package builds use the +published copies. MSVC is not part of the Bazel-produced matrix yet. The repository's current hermetic Windows C++ platform is `windows-gnullvm`/`x86_64-w64-windows-gnu`, so diff --git a/third_party/v8/libcxx.BUILD.bazel b/third_party/v8/libcxx.BUILD.bazel index 71a8f8ddce..dd3f167dfc 100644 --- a/third_party/v8/libcxx.BUILD.bazel +++ b/third_party/v8/libcxx.BUILD.bazel @@ -111,10 +111,11 @@ cc_runtime_stage0_library( "//conditions:default": ["-fPIC"], }), defines = [ - "CR_LIBCXX_REVISION=7ab65651aed6802d2599dcb7a73b1f82d5179d05", + "CR_LIBCXX_REVISION=99457fa555797f8c5ac3c076ca288d8481d3b23a", "LIBCXX_BUILDING_LIBCXXABI", "LIBC_NAMESPACE=__llvm_libc_cr", "_LIBCPP_BUILDING_LIBRARY", + "_LIBCPP_CONSTINIT=constinit", "_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", "_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", "_LIBCPP_INSTRUMENTED_WITH_ASAN=0", diff --git a/third_party/v8/libcxxabi.BUILD.bazel b/third_party/v8/libcxxabi.BUILD.bazel index 0645511db7..2afd6b76d1 100644 --- a/third_party/v8/libcxxabi.BUILD.bazel +++ b/third_party/v8/libcxxabi.BUILD.bazel @@ -65,6 +65,7 @@ cc_runtime_stage0_library( defines = [ "LIBCXXABI_SILENT_TERMINATE", "_LIBCPP_BUILDING_LIBRARY", + "_LIBCPP_CONSTINIT=constinit", "_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", "_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", "_LIBCPP_INSTRUMENTED_WITH_ASAN=0", diff --git a/third_party/v8/rusty_v8_147_4_0.sha256 b/third_party/v8/rusty_v8_147_4_0.sha256 deleted file mode 100644 index a287e5fd5b..0000000000 --- a/third_party/v8/rusty_v8_147_4_0.sha256 +++ /dev/null @@ -1,2 +0,0 @@ -1fa3f94d9e09cff1f6bcce94c478e5cb072c0755f6a0357abadb9dd3b48d8127 rusty_v8_release_aarch64-pc-windows-msvc.lib.gz -e2827ff98b1a9d4c0343000fc5124ac30dfab3007bc0129c168c9355fc2fcd7c rusty_v8_release_x86_64-pc-windows-msvc.lib.gz diff --git a/third_party/v8/rusty_v8_149_2_0.sha256 b/third_party/v8/rusty_v8_149_2_0.sha256 new file mode 100644 index 0000000000..4c97754744 --- /dev/null +++ b/third_party/v8/rusty_v8_149_2_0.sha256 @@ -0,0 +1,2 @@ +923f2b6ccdc14526b814e171e34c9aafd7969f12304948857c6696d022f0fb3c rusty_v8_release_aarch64-pc-windows-msvc.lib.gz +12b5a791b54e92f748738ad8d0d12dad8d281a2d836638ad1aa6678e3b855d9a rusty_v8_release_x86_64-pc-windows-msvc.lib.gz From 2ee3358c00a4d75db319d011013754a452ddddad Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 7 Jun 2026 09:24:54 -0700 Subject: [PATCH 101/141] ci: use bazel environment for BuildBuddy secret (#26895) ## Why `BUILDBUDDY_API_KEY` now lives in the `bazel` GitHub Actions environment as an environment secret. Jobs that need BuildBuddy credentials must opt into that environment so `${{ secrets.BUILDBUDDY_API_KEY }}` resolves from the protected environment secret instead of relying on an unscoped repository/organization secret. This follows the same environment-secret migration pattern as #26466. ## What Changed - Attach each workflow job that reads `BUILDBUDDY_API_KEY` to the `bazel` environment. - Set `deployment: false` on those job-level environment blocks. `deployment: false` lets the job enter the `bazel` environment to access its environment secrets without creating GitHub deployment records for these CI jobs. That keeps the environment as a secret/access-control boundary without making ordinary Bazel CI runs look like deploys. ## Validation - Parsed the modified workflow YAML files with Ruby's YAML parser. - Checked the modified workflow files for trailing whitespace. --- .github/workflows/bazel.yml | 15 +++++++++++++++ .github/workflows/rust-ci-full.yml | 3 +++ .github/workflows/rust-ci.yml | 3 +++ .github/workflows/rusty-v8-release.yml | 3 +++ .github/workflows/sdk.yml | 3 +++ .github/workflows/v8-canary.yml | 3 +++ 6 files changed, 30 insertions(+) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index cc1ef09245..809053069a 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -50,6 +50,9 @@ jobs: # Configure a human readable name for each job name: Bazel test on ${{ matrix.os }} for ${{ matrix.target }} + environment: + name: bazel + deployment: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -152,6 +155,9 @@ jobs: group: codex-runners labels: codex-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 + environment: + name: bazel + deployment: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -264,6 +270,9 @@ jobs: group: codex-runners labels: codex-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main) + environment: + name: bazel + deployment: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -352,6 +361,9 @@ jobs: labels: codex-windows-x64 runs-on: ${{ matrix.runs_on || matrix.os }} name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} + environment: + name: bazel + deployment: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -445,6 +457,9 @@ jobs: labels: codex-windows-x64 runs-on: ${{ matrix.runs_on || matrix.os }} name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }} + environment: + name: bazel + deployment: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 50ecf2f8d6..d22531baae 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -98,6 +98,9 @@ jobs: name: Argument comment lint - ${{ matrix.name }} runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: 30 + environment: + name: bazel + deployment: false strategy: fail-fast: false matrix: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 9b50ae4032..4528f4d963 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -159,6 +159,9 @@ jobs: runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: ${{ matrix.timeout_minutes }} needs: changed + environment: + name: bazel + deployment: false strategy: fail-fast: false matrix: diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 38f36856a8..92be3761dd 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -64,6 +64,9 @@ jobs: permissions: contents: read actions: read + environment: + name: bazel + deployment: false strategy: fail-fast: false matrix: diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 4103a948cd..42a44afdb5 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -46,6 +46,9 @@ jobs: group: codex-runners labels: codex-linux-x64 timeout-minutes: 10 + environment: + name: bazel + deployment: false steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index c590dec00f..523127b893 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -80,6 +80,9 @@ jobs: permissions: contents: read actions: read + environment: + name: bazel + deployment: false strategy: fail-fast: false matrix: From e6c470957de53572706afd19f7d8efb42985864c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 7 Jun 2026 11:33:16 -0700 Subject: [PATCH 102/141] fix: preserve approval sandbox decisions in unified exec (#24981) ## Why This PR fixes approval sandbox semantics in the unified-exec path. The zsh-fork runtime exposed the bug because the shell can do meaningful work before any intercepted child `execv(2)` exists: redirections, builtins, globbing, and pipeline setup all happen in the launch process. If the model requested `sandbox_permissions=require_escalated`, or an exec-policy `allow` rule explicitly bypassed the sandbox, that approved sandbox decision needs to be preserved for the launch path and for intercepted execs that use the same approval machinery. The behavior is not only about zsh fork. The production changes are in shared approval/escalation code, so they also affect non-zsh-fork intercepted exec paths that go through the same sandbox decision logic. The narrow intent is to preserve the approval decision while still keeping denied-read profiles and bounded additional-permission requests sandboxed. ## Production Changes - `codex-rs/core/src/tools/runtimes/unified_exec.rs`: derives a `launch_sandbox_permissions` value from the requested sandbox permissions and the runtime filesystem policy, then uses that value for managed-network/env setup and launch sandbox selection. This keeps full approval or policy-bypass decisions visible to the first unified-exec attempt, while still preventing a full sandbox override from discarding denied-read restrictions. Direct unified exec keeps the same decision surface; the important difference is that zsh-fork launch setup no longer accidentally loses the approved parent sandbox decision. - `codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs`: makes intercepted-exec escalation selection explicit for the three sandbox permission modes. `UseDefault` only escalates when an exec-policy decision allows sandbox bypass, `RequireEscalated` escalates when unsandboxed execution is allowed, and `WithAdditionalPermissions` escalates through the bounded additional-permissions path instead of being treated as a full unsandboxed override. Unsandboxed intercepted execs now also rebuild the environment as `RequireEscalated`, which strips managed-network proxy variables consistently with other unsandboxed execution. ## Test Coverage Most of the PR is tests. The new coverage verifies: - unified exec preserves parent approval and exec-policy sandbox decisions for zsh-fork launch selection; - bounded `with_additional_permissions` remains sandboxed and permission-profile based; - denied-read profiles are not weakened by parent approval; - explicit prompt rules still prompt for intercepted execs after the parent command is approved; - unsandboxed intercepted execs strip managed-network env vars. No documentation update is needed; this is an internal approval/sandbox correctness fix. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/24981). * #24982 * __->__ #24981 --- .../tools/runtimes/shell/unix_escalation.rs | 10 +- .../runtimes/shell/unix_escalation_tests.rs | 108 ++++ .../core/src/tools/runtimes/unified_exec.rs | 118 +++- codex-rs/core/tests/common/zsh_fork.rs | 27 + codex-rs/core/tests/suite/mod.rs | 2 + .../suite/unified_exec_zsh_fork_approvals.rs | 567 ++++++++++++++++++ 6 files changed, 820 insertions(+), 12 deletions(-) create mode 100644 codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs 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 6be1050210..71fa89f980 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -630,9 +630,11 @@ impl EscalationPolicy for CoreShellActionProvider { let decision_driven_by_policy = Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision); let unsandboxed_allowed = unsandboxed_execution_allowed(&self.file_system_sandbox_policy); - let needs_escalation = unsandboxed_allowed - && (self.sandbox_permissions.requires_escalated_permissions() - || decision_driven_by_policy); + let needs_escalation = match self.sandbox_permissions { + SandboxPermissions::UseDefault => unsandboxed_allowed && decision_driven_by_policy, + SandboxPermissions::RequireEscalated => unsandboxed_allowed, + SandboxPermissions::WithAdditionalPermissions => true, + }; let decision_source = if decision_driven_by_policy { DecisionSource::PrefixRule @@ -858,7 +860,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { EscalationExecution::Unsandboxed => PreparedExec { command, cwd: workdir.to_path_buf(), - env, + env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated), arg0: Some(first_arg.clone()), }, EscalationExecution::TurnDefault => { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 8cfbd0736d..ee5b9e7db3 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -1,4 +1,5 @@ use super::CoreShellActionProvider; +use super::CoreShellCommandExecutor; use super::InterceptedExecPolicyContext; use super::ParsedShellCommand; use super::commands_for_intercepted_exec_policy; @@ -16,6 +17,8 @@ use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; +use codex_network_proxy::PROXY_ENV_KEYS; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; @@ -30,13 +33,16 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianCommandSource; use codex_sandboxing::SandboxType; +use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; use codex_shell_escalation::ResolvedPermissionProfile; +use codex_shell_escalation::ShellCommandExecutor; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -349,6 +355,108 @@ fn shell_request_escalation_execution_is_explicit() { ); } +#[tokio::test] +async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Result<()> { + let workdir = test_sandbox_cwd(); + let executor = CoreShellCommandExecutor { + command: Vec::new(), + cwd: workdir.clone(), + permission_profile: PermissionProfile::workspace_write(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + env: HashMap::new(), + network: None, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + arg0: None, + sandbox_policy_cwd: workdir.clone(), + windows_sandbox_workspace_roots: vec![workdir.clone()], + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + }; + let mut env = HashMap::new(); + env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string()); + for key in PROXY_ENV_KEYS { + env.insert((*key).to_string(), format!("proxy-{key}")); + } + + let prepared = executor + .prepare_escalated_exec( + &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, + &["curl".to_string(), "example.com".to_string()], + &workdir, + env, + EscalationExecution::Unsandboxed, + ) + .await?; + + assert!( + !prepared.env.contains_key(PROXY_ACTIVE_ENV_KEY), + "unsandboxed intercepted exec should strip the managed-network active marker" + ); + for key in PROXY_ENV_KEYS { + assert!( + !prepared.env.contains_key(*key), + "unsandboxed intercepted exec should strip managed-network proxy env var {key}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyhow::Result<()> { + let (session, turn_context) = make_session_and_context().await; + let requested_permissions = AdditionalPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![ + AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(), + ]), + )), + ..Default::default() + }; + let workdir = test_sandbox_cwd(); + let permission_profile = effective_permission_profile( + &PermissionProfile::workspace_write(), + Some(&requested_permissions), + ); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "preapproved-additional-permissions".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::OnRequest, + permission_profile: permission_profile.clone(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + approval_sandbox_permissions: SandboxPermissions::UseDefault, + prompt_permissions: Some(requested_permissions), + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/printf")?, + &["printf".to_string(), "hello".to_string()], + &workdir, + ) + .await?; + + let expected = codex_shell_escalation::EscalationDecision::Escalate( + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { permission_profile }, + )), + ); + assert_eq!( + action, expected, + "preapproved with_additional_permissions should escalate through the resolved permission profile" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { let (session, mut turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index bf04a6fe35..250965b67c 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -265,17 +265,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt let base_command = &req.command; let session_shell = ctx.session.user_shell(); let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); - let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads( req.sandbox_permissions, &file_system_sandbox_policy, ); - let req = &UnifiedExecRequest { - sandbox_permissions, - ..req.clone() - }; - let managed_network = - managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions); - let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + let managed_network = managed_network_for_sandbox_permissions( + req.network.as_ref(), + launch_sandbox_permissions, + ); + let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); if let Some(network) = managed_network { network.apply_to_env(&mut env); } @@ -412,6 +410,7 @@ mod tests { use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; use crate::tools::sandboxing::ToolRuntime; use codex_exec_server::Environment; + use codex_tools::ZshForkConfig; use std::time::Duration; use tempfile::tempdir; @@ -473,4 +472,107 @@ mod tests { assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd)); } + + #[tokio::test] + async fn zsh_fork_first_attempt_preserves_parent_sandbox_override() { + let manager = UnifiedExecProcessManager::default(); + let request = test_request( + SandboxPermissions::RequireEscalated, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ); + let direct_runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); + + assert_eq!( + direct_runtime.sandbox_permissions(&request), + SandboxPermissions::RequireEscalated, + "direct unified exec should preserve a parent require_escalated request" + ); + assert_eq!( + zsh_fork_runtime.sandbox_permissions(&request), + SandboxPermissions::RequireEscalated, + "zsh-fork unified exec should preserve the same parent require_escalated request" + ); + } + + #[tokio::test] + async fn zsh_fork_first_attempt_preserves_additional_permissions_request() { + let manager = UnifiedExecProcessManager::default(); + let request = test_request( + SandboxPermissions::WithAdditionalPermissions, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ); + let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); + + assert_eq!( + zsh_fork_runtime.sandbox_permissions(&request), + SandboxPermissions::WithAdditionalPermissions, + "zsh-fork unified exec should keep bounded additional-permissions requests sandboxed" + ); + } + + #[tokio::test] + async fn zsh_fork_execpolicy_allow_preserves_parent_sandbox_override() { + let manager = UnifiedExecProcessManager::default(); + let request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }, + ); + let runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); + + assert_eq!( + runtime.exec_approval_requirement(&request), + Some(ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }), + "zsh-fork unified exec should preserve exec-policy allow decisions that bypass the sandbox" + ); + } + + fn test_request( + sandbox_permissions: SandboxPermissions, + exec_approval_requirement: ExecApprovalRequirement, + ) -> UnifiedExecRequest { + let cwd = AbsolutePathBuf::try_from(std::env::current_dir().unwrap()) + .expect("current dir is absolute"); + UnifiedExecRequest { + command: vec!["zsh".to_string(), "-c".to_string(), "echo hi".to_string()], + shell_type: ShellType::Zsh, + hook_command: "echo hi".to_string(), + process_id: 1000, + cwd: cwd.clone(), + sandbox_cwd: cwd, + environment: Arc::new(Environment::default_for_tests()), + env: HashMap::new(), + exec_server_env_config: None, + explicit_env_overrides: HashMap::new(), + network: None, + tty: false, + sandbox_permissions, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: None, + exec_approval_requirement, + } + } + + fn zsh_fork_mode() -> UnifiedExecShellMode { + let cwd = std::env::current_dir().expect("read current dir"); + UnifiedExecShellMode::ZshFork(ZshForkConfig { + shell_zsh_path: AbsolutePathBuf::try_from(cwd.join("zsh")).expect("absolute zsh path"), + main_execve_wrapper_exe: AbsolutePathBuf::try_from(cwd.join("execve-wrapper")) + .expect("absolute wrapper path"), + }) + } } diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index e58ebd81dd..5abfc753b9 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -94,6 +94,33 @@ where builder.build(server).await } +pub async fn build_unified_exec_zsh_fork_test( + server: &wiremock::MockServer, + runtime: ZshForkRuntime, + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + pre_build_hook: F, +) -> Result +where + F: FnOnce(&Path) + Send + 'static, +{ + let mut builder = test_codex() + .with_pre_build_hook(pre_build_hook) + .with_config(move |config| { + runtime.apply_to_config(config, approval_policy, permission_profile); + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExecZshFork) + .expect("test config should allow feature update"); + }); + builder.build(server).await +} + fn find_test_zsh_path() -> Result> { let repo_root = codex_utils_cargo_bin::repo_root()?; let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index ce853e810f..11246d7cc4 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -115,6 +115,8 @@ mod tools; mod truncation; mod turn_state; mod unified_exec; +#[cfg(unix)] +mod unified_exec_zsh_fork_approvals; mod unstable_features_warning; mod user_notification; mod user_shell_cmd; diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs new file mode 100644 index 0000000000..96ad8ee59a --- /dev/null +++ b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs @@ -0,0 +1,567 @@ +use anyhow::Context; +use anyhow::Result; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::types::ApprovalsReviewer; +use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ResponseMock; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +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::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use core_test_support::zsh_fork::build_unified_exec_zsh_fork_test; +use core_test_support::zsh_fork::restrictive_workspace_write_profile; +use core_test_support::zsh_fork::zsh_fork_runtime; +use pretty_assertions::assert_eq; +use regex_lite::Regex; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::time::Duration; +use toml_edit::Key as TomlKey; +use wiremock::MockServer; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_zsh_fork_parent_approval_preserves_denied_reads() -> Result<()> { + skip_if_no_network!(Ok(())); + + let denied_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let denied_path = denied_dir.path().join("secret.env"); + let secret = "unified-exec-zsh-fork-denied-read-secret"; + fs::write(&denied_path, format!("{secret}\n"))?; + let permission_profile = denied_read_permission_profile(&denied_path)?; + assert!( + permission_profile + .file_system_sandbox_policy() + .has_denied_read_restrictions(), + "test must exercise a permission profile with denied reads" + ); + + let approval_policy = AskForApproval::OnRequest; + let command = format!("cat {denied_path:?}"); + let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( + "unified-exec zsh-fork denied-read approval test", + approval_policy, + permission_profile, + move |_home| {}, + ) + .await? + else { + return Ok(()); + }; + + let call_id = "uexec-zsh-fork-parent-approval-denied-read"; + let results = mount_unified_exec_command( + &server, + "uexec-zsh-fork-denied-read", + call_id, + &command, + "attempt a denied read for the test", + ) + .await?; + submit_turn_with_session_permissions( + &test, + "run approved unified exec denied read through zsh fork", + approval_policy, + ) + .await?; + approve_expected_exec(&test, &command).await?; + wait_for_completion_without_approval(&test).await; + + let result = command_result(&results, call_id); + assert_ne!( + result.exit_code.unwrap_or(0), + 0, + "denied-read command should stay sandboxed after parent approval" + ); + assert!( + !result.stdout.contains(secret), + "denied-read command unexpectedly printed the secret: {}", + result.stdout + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_zsh_fork_parent_approval_escalates_intercepted_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + + let approval_policy = AskForApproval::OnRequest; + let permission_profile = restrictive_workspace_write_profile(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir + .path() + .join("unified-exec-zsh-fork-parent-approval.txt"); + let command = format!("printf hi > {outside_path:?}"); + + let outside_path_for_hook = outside_path.clone(); + let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( + "unified-exec zsh-fork parent approval test", + approval_policy, + permission_profile, + move |_home| { + let _ = fs::remove_file(&outside_path_for_hook); + }, + ) + .await? + else { + return Ok(()); + }; + + let call_id = "uexec-zsh-fork-parent-approval"; + let results = mount_unified_exec_command( + &server, + "uexec-zsh-fork-parent-approval", + call_id, + &command, + "write outside the workspace for the test", + ) + .await?; + submit_turn_with_session_permissions( + &test, + "run approved unified exec through zsh fork", + approval_policy, + ) + .await?; + approve_expected_exec(&test, &command).await?; + wait_for_completion_without_approval(&test).await; + + let result = command_result(&results, call_id); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "approved unified exec zsh-fork command should complete: {}", + result.stdout + ); + let contents = fs::read_to_string(&outside_path) + .with_context(|| format!("read {}", outside_path.display()))?; + assert_eq!( + contents, "hi", + "approved parent sandbox override should allow zsh-fork shell redirection to write outside the workspace" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_zsh_fork_parent_approval_keeps_explicit_prompt_rule() -> Result<()> { + skip_if_no_network!(Ok(())); + + let approval_policy = AskForApproval::OnRequest; + let permission_profile = restrictive_workspace_write_profile(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir + .path() + .join("unified-exec-zsh-fork-explicit-prompt-rule.txt"); + let command = format!("touch {outside_path:?}"); + let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); + + let outside_path_for_hook = outside_path.clone(); + let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( + "unified-exec zsh-fork prompt rule approval test", + approval_policy, + permission_profile, + move |home| { + let _ = fs::remove_file(&outside_path_for_hook); + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).unwrap(); + fs::write(rules_dir.join("default.rules"), &rules).unwrap(); + }, + ) + .await? + else { + return Ok(()); + }; + + let call_id = "uexec-zsh-fork-parent-approval-explicit-prompt-rule"; + let results = mount_unified_exec_command( + &server, + "uexec-zsh-fork-prompt-rule", + call_id, + &command, + "write outside the workspace for the test", + ) + .await?; + submit_turn_with_session_permissions( + &test, + "run approved unified exec prompt rule through zsh fork", + approval_policy, + ) + .await?; + approve_expected_exec(&test, &command).await?; + + let approval_event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + Duration::from_secs(10), + ) + .await; + let EventMsg::ExecApprovalRequest(inner_approval) = approval_event else { + panic!("expected explicit prompt rule approval before completion"); + }; + assert!( + inner_approval + .command + .iter() + .any(|arg| arg.ends_with("/touch")) + && inner_approval + .command + .iter() + .any(|arg| arg == outside_path.to_string_lossy().as_ref()), + "expected explicit prompt rule approval for intercepted touch, got: {:?}", + inner_approval.command + ); + + approve_exec(&test, inner_approval.effective_approval_id()).await?; + wait_for_completion(&test).await; + + let result = command_result(&results, call_id); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "approved unified exec zsh-fork prompt-rule command should complete: {}", + result.stdout + ); + assert!( + outside_path.exists(), + "approved intercepted touch should create the out-of-workspace file" + ); + + Ok(()) +} + +struct CommandResult { + exit_code: Option, + stdout: String, +} + +async fn build_unified_exec_zsh_fork_test_or_skip( + test_name: &str, + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + pre_build_hook: F, +) -> Result> +where + F: FnOnce(&Path) + Send + 'static, +{ + let Some(runtime) = zsh_fork_runtime(test_name)? else { + return Ok(None); + }; + + let server = start_mock_server().await; + let test = build_unified_exec_zsh_fork_test( + &server, + runtime, + approval_policy, + permission_profile, + pre_build_hook, + ) + .await?; + Ok(Some((server, test))) +} + +fn denied_read_permission_profile(denied_path: &Path) -> Result { + let denied_path_key = TomlKey::new(denied_path.to_string_lossy().into_owned()); + permission_profile_from_toml(&format!( + r#" +[filesystem] +"/" = "read" +":project_roots" = "write" +{denied_path_key} = "deny" + +[network] +enabled = false +"# + )) +} + +fn permission_profile_from_toml(profile: &str) -> Result { + let profile = toml::from_str::(profile) + .context("test permission profile should deserialize")?; + let filesystem = profile + .filesystem + .as_ref() + .context("test permission profile should include filesystem entries")?; + let entries = filesystem + .entries + .iter() + .map(|(path, permission)| { + let FilesystemPermissionToml::Access(access) = permission else { + anyhow::bail!("unexpected scoped filesystem permission in test profile: {path}"); + }; + let path = match path.as_str() { + "/" => FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + ":project_roots" => FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + _ if *access == FileSystemAccessMode::Deny => FileSystemPath::GlobPattern { + pattern: path.clone(), + }, + _ => anyhow::bail!("unexpected filesystem entry in test profile: {path}"), + }; + Ok(FileSystemSandboxEntry { + path, + access: *access, + }) + }) + .collect::>>()?; + let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries); + file_system_sandbox_policy.glob_scan_max_depth = filesystem.glob_scan_max_depth; + let network_sandbox_policy = match profile.network.as_ref().and_then(|network| network.enabled) + { + Some(true) => NetworkSandboxPolicy::Enabled, + Some(false) | None => NetworkSandboxPolicy::Restricted, + }; + + Ok(PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + )) +} + +async fn mount_unified_exec_command( + server: &MockServer, + response_prefix: &str, + call_id: &str, + command: &str, + justification: &str, +) -> Result { + let first_response_id = format!("resp-{response_prefix}-1"); + let second_response_id = format!("resp-{response_prefix}-2"); + let message_id = format!("msg-{response_prefix}-1"); + let event = exec_command_event( + call_id, + command, + Some(30_000), + SandboxPermissions::RequireEscalated, + justification, + )?; + let _ = mount_sse_once( + server, + sse(vec![ + ev_response_created(&first_response_id), + event, + ev_completed(&first_response_id), + ]), + ) + .await; + let results = mount_sse_once( + server, + sse(vec![ + ev_assistant_message(&message_id, "done"), + ev_completed(&second_response_id), + ]), + ) + .await; + Ok(results) +} + +async fn submit_turn_with_session_permissions( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = turn_permission_fields( + test.session_configured.permission_profile.clone(), + test.cwd.path(), + ); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + cwd: Some(test.config.cwd.clone()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(()) +} + +async fn approve_expected_exec(test: &TestCodex, expected_command: &str) -> Result<()> { + let approval = expect_exec_approval(test, expected_command).await; + approve_exec(test, approval.effective_approval_id()).await +} + +async fn approve_exec(test: &TestCodex, approval_id: String) -> Result<()> { + test.codex + .submit(Op::ExecApproval { + id: approval_id, + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + Ok(()) +} + +fn command_result(results: &ResponseMock, call_id: &str) -> CommandResult { + parse_result(&results.single_request().function_call_output(call_id)) +} + +fn exec_command_event( + call_id: &str, + cmd: &str, + yield_time_ms: Option, + sandbox_permissions: SandboxPermissions, + justification: &str, +) -> Result { + let mut args = json!({ + "cmd": cmd.to_string(), + }); + if let Some(yield_time_ms) = yield_time_ms { + args["yield_time_ms"] = json!(yield_time_ms); + } + if sandbox_permissions.requests_sandbox_override() { + args["sandbox_permissions"] = json!(sandbox_permissions); + args["justification"] = json!(justification); + } + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn parse_result(item: &Value) -> CommandResult { + let Some(output_str) = item.get("output").and_then(Value::as_str) else { + return CommandResult { + exit_code: None, + stdout: String::new(), + }; + }; + match serde_json::from_str::(output_str) { + Ok(parsed) => { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + CommandResult { exit_code, stdout } + } + Err(_) => parsed_regex_result(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$", output_str) + .or_else(|| { + parsed_regex_result( + r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$", + output_str, + ) + }) + .unwrap_or_else(|| CommandResult { + exit_code: None, + stdout: output_str.to_string(), + }), + } +} + +fn parsed_regex_result(pattern: &str, output_str: &str) -> Option { + let regex = Regex::new(pattern).ok()?; + let captures = regex.captures(output_str)?; + let exit_code = captures.get(1)?.as_str().parse::().ok()?; + let output = captures.get(2)?.as_str(); + Some(CommandResult { + exit_code: Some(exit_code), + stdout: output.to_string(), + }) +} + +async fn expect_exec_approval( + test: &TestCodex, + expected_command: &str, +) -> ExecApprovalRequestEvent { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => { + let last_arg = approval + .command + .last() + .map(std::string::String::as_str) + .unwrap_or_default(); + assert_eq!( + last_arg, expected_command, + "approval request should be for the parent unified-exec command" + ); + approval + } + EventMsg::TurnComplete(_) => panic!("expected approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion_without_approval(test: &TestCodex) { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::TurnComplete(_) => {} + EventMsg::ExecApprovalRequest(event) => { + panic!("unexpected approval request: {:?}", event.command) + } + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; +} From e093d819826127be01354e2885c86d7fcbfb2897 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 7 Jun 2026 17:34:35 -0400 Subject: [PATCH 103/141] fix(tui): accept prompts with resume and fork (#26818) ## Why Interactive `codex resume` and `codex fork` expose both a session ID positional and an initial prompt positional. With `--last`, Clap still assigns the first positional to the session ID, so a command such as `codex fork --last "/compact focus on auth"` either fails parsing or attempts to look up the prompt as a session ID instead of sending it to the latest session. This makes it impossible to select the latest session and immediately provide a follow-up prompt, even though `codex exec resume --last` already supports that workflow. CleanShot 2026-06-06 at 17 00
47@2x ## What Changed - Reinterpret the first positional as the initial prompt when interactive `resume --last` or `fork --last` is used and no explicit second prompt was parsed. - Preserve the existing `resume SESSION_ID PROMPT` and `fork SESSION_ID PROMPT` behavior. - Add parser-level regression coverage for latest-session and explicit-session prompt forms. ## How to Test 1. Start an interactive session, exit it, then run `codex resume --last "continue from the latest session"`. 2. Confirm Codex resumes the latest session and submits the supplied prompt instead of treating it as a session ID. 3. Run `codex fork --last "take a different approach"`. 4. Confirm Codex forks the latest session and submits the supplied prompt. 5. Also verify `codex resume SESSION_ID "continue here"` and `codex fork SESSION_ID "branch here"` still target the explicit session and submit the prompt. Targeted tests: - `just test -p codex-cli` (267 passed) --- codex-rs/cli/src/main.rs | 127 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2a5ce171b7..bb0daf3890 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -324,7 +324,7 @@ struct ResumeCommand { remote: InteractiveRemoteOptions, #[clap(flatten)] - config_overrides: TuiCli, + config_overrides: SessionTuiCli, } #[derive(Debug, Parser)] @@ -361,7 +361,7 @@ struct ForkCommand { session_id: Option, /// Fork the most recent session without showing the picker. - #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] + #[arg(long = "last", default_value_t = false)] last: bool, /// Show all sessions (disables cwd filtering and shows CWD column). @@ -372,7 +372,33 @@ struct ForkCommand { remote: InteractiveRemoteOptions, #[clap(flatten)] - config_overrides: TuiCli, + config_overrides: SessionTuiCli, +} + +/// TUI arguments for session commands where a parsed prompt implies an explicit session id. +/// +/// This keeps `--last PROMPT` valid while rejecting `--last SESSION_ID PROMPT`. +#[derive(Debug)] +struct SessionTuiCli(TuiCli); + +impl Args for SessionTuiCli { + fn augment_args(cmd: clap::Command) -> clap::Command { + TuiCli::augment_args(cmd).mut_arg("prompt", |arg| arg.conflicts_with("last")) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + TuiCli::augment_args_for_update(cmd).mut_arg("prompt", |arg| arg.conflicts_with("last")) + } +} + +impl clap::FromArgMatches for SessionTuiCli { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + TuiCli::from_arg_matches(matches).map(Self) + } + + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + self.0.update_from_arg_matches(matches) + } } #[cfg(target_os = "macos")] @@ -1172,6 +1198,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { remote, config_overrides, })) => { + let SessionTuiCli(config_overrides) = config_overrides; interactive = finalize_resume_interactive( interactive, root_config_overrides.clone(), @@ -1225,6 +1252,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { remote, config_overrides, })) => { + let SessionTuiCli(config_overrides) = config_overrides; interactive = finalize_fork_interactive( interactive, root_config_overrides.clone(), @@ -2252,11 +2280,18 @@ fn finalize_resume_interactive( last: bool, show_all: bool, include_non_interactive: bool, - resume_cli: TuiCli, + mut resume_cli: TuiCli, ) -> TuiCli { // Start with the parsed interactive CLI so resume shares the same // configuration surface area as `codex` without additional flags. - let resume_session_id = session_id; + // Clap assigns the first positional to `session_id`. With `--last`, reinterpret it as the + // prompt when no second positional prompt was provided. + let resume_session_id = if last && resume_cli.prompt.is_none() { + resume_cli.prompt = session_id; + None + } else { + session_id + }; interactive.resume_picker = resume_session_id.is_none() && !last; interactive.resume_last = last; interactive.resume_session_id = resume_session_id; @@ -2279,11 +2314,18 @@ fn finalize_fork_interactive( session_id: Option, last: bool, show_all: bool, - fork_cli: TuiCli, + mut fork_cli: TuiCli, ) -> TuiCli { // Start with the parsed interactive CLI so fork shares the same // configuration surface area as `codex` without additional flags. - let fork_session_id = session_id; + // Clap assigns the first positional to `session_id`. With `--last`, reinterpret it as the + // prompt when no second positional prompt was provided. + let fork_session_id = if last && fork_cli.prompt.is_none() { + fork_cli.prompt = session_id; + None + } else { + session_id + }; interactive.fork_picker = fork_session_id.is_none() && !last; interactive.fork_last = last; interactive.fork_session_id = fork_session_id; @@ -2448,6 +2490,7 @@ mod tests { else { unreachable!() }; + let SessionTuiCli(resume_cli) = resume_cli; finalize_resume_interactive( interactive, @@ -2480,6 +2523,7 @@ mod tests { else { unreachable!() }; + let SessionTuiCli(fork_cli) = fork_cli; finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } @@ -3015,6 +3059,30 @@ mod tests { assert!(!interactive.resume_show_all); } + #[test] + fn resume_last_accepts_prompt_positional() { + let interactive = finalize_resume_from_args( + ["codex", "resume", "--last", "/compact focus on auth"].as_ref(), + ); + + assert!(!interactive.resume_picker); + assert!(interactive.resume_last); + assert_eq!(interactive.resume_session_id, None); + assert_eq!( + interactive.prompt.as_deref(), + Some("/compact focus on auth") + ); + } + + #[test] + fn resume_last_rejects_explicit_session_and_prompt() { + let err = + MultitoolCli::try_parse_from(["codex", "resume", "--last", "1234", "continue here"]) + .expect_err("--last with an explicit session and prompt should be rejected"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn resume_picker_logic_with_session_id() { let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref()); @@ -3024,6 +3092,17 @@ mod tests { assert!(!interactive.resume_show_all); } + #[test] + fn resume_with_session_id_accepts_prompt_positional() { + let interactive = + finalize_resume_from_args(["codex", "resume", "1234", "continue here"].as_ref()); + + assert!(!interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); + assert_eq!(interactive.prompt.as_deref(), Some("continue here")); + } + #[test] fn resume_all_flag_sets_show_all() { let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref()); @@ -3143,6 +3222,29 @@ mod tests { assert!(!interactive.fork_show_all); } + #[test] + fn fork_last_accepts_prompt_positional() { + let interactive = + finalize_fork_from_args(["codex", "fork", "--last", "/compact focus on auth"].as_ref()); + + assert!(!interactive.fork_picker); + assert!(interactive.fork_last); + assert_eq!(interactive.fork_session_id, None); + assert_eq!( + interactive.prompt.as_deref(), + Some("/compact focus on auth") + ); + } + + #[test] + fn fork_last_rejects_explicit_session_and_prompt() { + let err = + MultitoolCli::try_parse_from(["codex", "fork", "--last", "1234", "continue here"]) + .expect_err("--last with an explicit session and prompt should be rejected"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn fork_picker_logic_with_session_id() { let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref()); @@ -3152,6 +3254,17 @@ mod tests { assert!(!interactive.fork_show_all); } + #[test] + fn fork_with_session_id_accepts_prompt_positional() { + let interactive = + finalize_fork_from_args(["codex", "fork", "1234", "continue here"].as_ref()); + + assert!(!interactive.fork_picker); + assert!(!interactive.fork_last); + assert_eq!(interactive.fork_session_id.as_deref(), Some("1234")); + assert_eq!(interactive.prompt.as_deref(), Some("continue here")); + } + #[test] fn fork_all_flag_sets_show_all() { let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref()); From e648ec771f1f130e82110c9b30932361dcefe85f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 7 Jun 2026 17:35:33 -0700 Subject: [PATCH 104/141] deps: update starlark to 0.14.2 (#24820) --- MODULE.bazel.lock | 86 +- codex-rs/.cargo/audit.toml | 7 +- codex-rs/Cargo.lock | 879 +++++++++++------- codex-rs/Cargo.toml | 2 +- codex-rs/deny.toml | 7 +- codex-rs/execpolicy-legacy/src/arg_matcher.rs | 2 +- codex-rs/execpolicy-legacy/src/opt.rs | 2 +- .../execpolicy-legacy/src/policy_parser.rs | 42 +- codex-rs/execpolicy/src/parser.rs | 9 +- 9 files changed, 612 insertions(+), 424 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index c55ceb6e01..707151a49e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -613,8 +613,8 @@ "age_0.11.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"aes-gcm\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"age-core\",\"req\":\"^0.11.0\"},{\"name\":\"base64\",\"req\":\"^0.21\"},{\"name\":\"bcrypt-pbkdf\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"bech32\",\"req\":\"^0.9\"},{\"name\":\"cbc\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"chacha20poly1305\",\"req\":\"^0.10\"},{\"features\":[\"alloc\"],\"name\":\"cipher\",\"optional\":true,\"req\":\"^0.4.3\"},{\"default_features\":false,\"name\":\"console\",\"optional\":true,\"req\":\"^0.15\"},{\"name\":\"cookie-factory\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion-cycles-per-byte\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"ctr\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"curve25519-dalek\",\"optional\":true,\"req\":\"^4\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"hmac\",\"req\":\"^0.12\"},{\"features\":[\"fluent-system\"],\"name\":\"i18n-embed\",\"req\":\"^0.15\"},{\"features\":[\"fluent-system\",\"desktop-requester\"],\"kind\":\"dev\",\"name\":\"i18n-embed\",\"req\":\"^0.15\"},{\"name\":\"i18n-embed-fl\",\"req\":\"^0.9\"},{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"nom\",\"req\":\"^7\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"pin-project\",\"req\":\"^1\"},{\"name\":\"pinentry\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"criterion\",\"flamegraph\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.13\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rpassword\",\"optional\":true,\"req\":\"^7\"},{\"default_features\":false,\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"rust-embed\",\"req\":\"^8\"},{\"default_features\":false,\"name\":\"scrypt\",\"req\":\"^0.11\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"subtle\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"Window\",\"Performance\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(any(unix, windows))\"},{\"name\":\"wsl\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(any(unix, windows))\"},{\"features\":[\"static_secrets\"],\"name\":\"x25519-dalek\",\"req\":\"^2\"},{\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"armor\":[],\"async\":[\"futures\",\"memchr\"],\"cli-common\":[\"console\",\"is-terminal\",\"pinentry\",\"rpassword\"],\"default\":[],\"plugin\":[\"age-core/plugin\",\"which\",\"wsl\"],\"ssh\":[\"aes\",\"aes-gcm\",\"bcrypt-pbkdf\",\"cbc\",\"cipher\",\"ctr\",\"curve25519-dalek\",\"num-traits\",\"rsa\"],\"unstable\":[\"age-core/unstable\"]}}", "ahash_0.8.12": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"const-random\",\"optional\":true,\"req\":\"^0.1.17\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.2\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"once_cell\",\"req\":\"^1.18.0\",\"target\":\"cfg(not(all(target_arch = \\\"arm\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"pcg-mwc\",\"req\":\"^0.2.1\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^4.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.59\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"kind\":\"build\",\"name\":\"version_check\",\"req\":\"^0.9.4\"},{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.24\"}],\"features\":{\"atomic-polyfill\":[\"dep:portable-atomic\",\"once_cell/critical-section\"],\"compile-time-rng\":[\"const-random\"],\"default\":[\"std\",\"runtime-rng\"],\"nightly-arm-aes\":[],\"no-rng\":[],\"runtime-rng\":[\"getrandom\"],\"std\":[]}}", "aho-corasick_1.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\",\"perf-literal\"],\"logging\":[\"dep:log\"],\"perf-literal\":[\"dep:memchr\"],\"std\":[\"memchr?/std\"]}}", - "allocative_0.3.4": "{\"dependencies\":[{\"name\":\"allocative_derive\",\"req\":\"=0.3.3\"},{\"name\":\"anyhow\",\"optional\":true,\"req\":\"^1.0.65\"},{\"name\":\"bumpalo\",\"optional\":true,\"req\":\"^3.11.1\"},{\"name\":\"compact_str\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"ctor\",\"req\":\"^0.1.26\"},{\"name\":\"dashmap\",\"optional\":true,\"req\":\"^5.5.3\"},{\"name\":\"either\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.24\"},{\"features\":[\"raw\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.14.3\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"inferno\",\"req\":\"^0.11.11\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.15.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.11.2\"},{\"name\":\"prost-types\",\"optional\":true,\"req\":\"^0.11.2\"},{\"name\":\"relative-path\",\"optional\":true,\"req\":\"^1.7.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.48\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.10.0\"},{\"name\":\"sorted_vector_map\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.5\"},{\"name\":\"triomphe\",\"optional\":true,\"req\":\"^0.1.8\"}],\"features\":{}}", - "allocative_derive_0.3.3": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "allocative_0.3.6": "{\"dependencies\":[{\"name\":\"allocative_derive\",\"req\":\"=0.3.6\"},{\"name\":\"anyhow\",\"optional\":true,\"req\":\"^1.0.65\"},{\"name\":\"bumpalo\",\"optional\":true,\"req\":\"^3.11.1\"},{\"name\":\"compact_str\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"ctor\",\"req\":\"^1.0.5\"},{\"name\":\"dashmap\",\"optional\":true,\"req\":\"^6.1.0\"},{\"name\":\"either\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.24\"},{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.16.1\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"inferno\",\"req\":\"^0.11.11\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.21.4\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.11.2\"},{\"name\":\"prost-types\",\"optional\":true,\"req\":\"^0.14.3\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.48\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.12\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.10.0\"},{\"name\":\"sorted_vector_map\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.5\"},{\"name\":\"triomphe\",\"optional\":true,\"req\":\"^0.1.8\"}],\"features\":{\"anyhow\":[\"dep:anyhow\"],\"bumpalo\":[\"dep:bumpalo\"],\"compact_str\":[\"dep:compact_str\"],\"dashmap\":[\"dep:dashmap\"],\"default\":[],\"either\":[\"dep:either\"],\"futures\":[\"dep:futures\"],\"hashbrown\":[\"dep:hashbrown\"],\"indexmap\":[\"dep:indexmap\"],\"num-bigint\":[\"dep:num-bigint\"],\"once_cell\":[\"dep:once_cell\"],\"parking_lot\":[\"dep:parking_lot\"],\"prost-types\":[\"dep:prost-types\"],\"serde_json\":[\"dep:serde_json\"],\"slab\":[\"dep:slab\"],\"smallvec\":[\"dep:smallvec\"],\"sorted_vector_map\":[\"dep:sorted_vector_map\"],\"tokio\":[\"dep:tokio\"],\"triomphe\":[\"dep:triomphe\"]}}", + "allocative_derive_0.3.6": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"name\":\"quote\",\"req\":\"^1.0.45\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.117\"}],\"features\":{}}", "allocator-api2_0.2.21": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"fresh-rust\":[],\"nightly\":[],\"std\":[\"alloc\"]}}", "alsa-sys_0.3.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.65\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3\"}],\"features\":{}}", "alsa_0.9.1": "{\"dependencies\":[{\"name\":\"alsa-sys\",\"req\":\"^0.3.1\"},{\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -629,13 +629,12 @@ "anstyle-wincon_3.0.11": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"name\":\"once_cell_polyfill\",\"req\":\"^1.56.1\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_System_Console\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.60.2, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "anstyle_1.0.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anstyle_1.0.14": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.23\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "anyhow_1.0.101": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anyhow_1.0.102": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"backtrace\":[],\"default\":[\"std\"],\"std\":[]}}", "arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"derive_arbitrary\",\"optional\":true,\"req\":\"~1.4.0\"},{\"kind\":\"dev\",\"name\":\"exhaustigen\",\"req\":\"^0.1.0\"}],\"features\":{\"derive\":[\"derive_arbitrary\"]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", "arc-swap_1.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arrayref_0.3.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"}],\"features\":{}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "asn1-rs-derive_0.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"synstructure\",\"req\":\"^0.13\"}],\"features\":{}}", "asn1-rs-impl_0.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", @@ -658,7 +657,9 @@ "async-trait_0.1.89": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\",\"proc-macro\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"kind\":\"dev\",\"name\":\"tracing-attributes\",\"req\":\"^0.1.27\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", "asynk-strim_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-fn-stream\",\"req\":\"^0.3.2\"},{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\",\"plotters\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.3.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.99\"}],\"features\":{}}", "atoi_2.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.14\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"num-traits/std\"]}}", + "atomic-polyfill_1.0.3": "{\"dependencies\":[{\"name\":\"critical-section\",\"req\":\"^1.0.0\"}],\"features\":{}}", "atomic-waker_1.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7.0\"}],\"features\":{}}", + "atomic_0.5.3": "{\"dependencies\":[],\"features\":{\"default\":[\"fallback\"],\"fallback\":[],\"nightly\":[],\"std\":[]}}", "autocfg_1.5.0": "{\"dependencies\":[],\"features\":{}}", "aws-config_1.8.12": "{\"dependencies\":[{\"features\":[\"test-util\"],\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"default_features\":false,\"name\":\"aws-sdk-signin\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sso\",\"optional\":true,\"req\":\"^1.91.0\"},{\"default_features\":false,\"name\":\"aws-sdk-ssooidc\",\"optional\":true,\"req\":\"^1.93.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sts\",\"req\":\"^1.95.0\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"default-client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"base64-simd\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"p256\",\"optional\":true,\"req\":\"^0.13.2\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.5\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.9\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.13.1\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"fmt\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.18.1\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"allow-compilation\":[],\"behavior-version-latest\":[],\"client-hyper\":[\"aws-smithy-runtime/default-https-client\"],\"credentials-login\":[\"dep:aws-sdk-signin\",\"dep:sha2\",\"dep:zeroize\",\"dep:hex\",\"dep:base64-simd\",\"dep:uuid\",\"uuid?/v4\",\"dep:p256\",\"p256?/arithmetic\",\"p256?/pem\",\"dep:rand\"],\"credentials-process\":[\"tokio/process\"],\"default\":[\"default-https-client\",\"rt-tokio\",\"credentials-process\",\"sso\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-runtime/rt-tokio\",\"tokio/rt\"],\"rustls\":[\"client-hyper\"],\"sso\":[\"dep:aws-sdk-sso\",\"dep:aws-sdk-ssooidc\",\"dep:ring\",\"dep:hex\",\"dep:zeroize\",\"aws-smithy-runtime-api/http-auth\"],\"test-util\":[\"aws-runtime/test-util\"]}}", "aws-credential-types_1.2.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.74\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"client\",\"http-auth\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"full\",\"test-util\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"zeroize\",\"req\":\"^1.7.0\"}],\"features\":{\"hardcoded-credentials\":[],\"test-util\":[\"aws-smithy-runtime-api/test-util\"]}}", @@ -697,12 +698,13 @@ "beef_0.5.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.105\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.105\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"const_fn\":[],\"default\":[],\"impl_serde\":[\"serde\"]}}", "bincode_1.3.3": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1.0.63\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.27\"}],\"features\":{\"i128\":[]}}", "bindgen_0.72.1": "{\"dependencies\":[{\"name\":\"annotate-snippets\",\"optional\":true,\"req\":\"^0.11.4\"},{\"name\":\"bitflags\",\"req\":\"^2.2.1\"},{\"name\":\"cexpr\",\"req\":\"^0.6\"},{\"features\":[\"clang_11_0\"],\"name\":\"clang-sys\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4\"},{\"name\":\"clap_complete\",\"optional\":true,\"req\":\"^4\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\">=0.10, <0.14\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"verbatim\"],\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.7\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-perl\"],\"name\":\"regex\",\"req\":\"^1.5.3\"},{\"name\":\"rustc-hash\",\"req\":\"^2.1.0\"},{\"name\":\"shlex\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"__cli\":[\"dep:clap\",\"dep:clap_complete\"],\"__testing_only_extra_assertions\":[],\"__testing_only_libclang_16\":[],\"__testing_only_libclang_9\":[],\"default\":[\"logging\",\"prettyplease\",\"runtime\"],\"experimental\":[\"dep:annotate-snippets\"],\"logging\":[\"dep:log\"],\"runtime\":[\"clang-sys/runtime\"],\"static\":[\"clang-sys/static\"]}}", - "bit-set_0.5.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bit-vec\",\"req\":\"^0.6.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"bit-vec/std\"]}}", - "bit-vec_0.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", + "bit-set_0.8.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bit-vec\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\",\"bit-vec/serde\"],\"std\":[\"bit-vec/std\"]}}", + "bit-vec_0.8.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.5\"},{\"name\":\"miniserde\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"nanoserde\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"borsh_std\":[\"borsh/std\"],\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", "bitflags_1.3.2": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3\"}],\"features\":{\"default\":[],\"example_generated\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\"]}}", "bitflags_2.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "blake2_0.10.6": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"}],\"features\":{\"default\":[\"std\"],\"reset\":[],\"simd\":[],\"simd_asm\":[\"simd_opt\"],\"simd_opt\":[\"simd\"],\"size_opt\":[],\"std\":[\"digest/std\"]}}", + "blake3_1.8.2": "{\"dependencies\":[{\"name\":\"arrayref\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"arrayvec\",\"req\":\"^0.7.4\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.1.12\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"ciborium\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"name\":\"constant_time_eq\",\"req\":\"^0.3.1\"},{\"features\":[\"mac\"],\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10.1\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"memmap2\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"page_size\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.9.0\"},{\"name\":\"rayon-core\",\"optional\":true,\"req\":\"^1.12.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"digest\":[\"dep:digest\"],\"mmap\":[\"std\",\"dep:memmap2\"],\"neon\":[],\"no_avx2\":[],\"no_avx512\":[],\"no_neon\":[],\"no_sse2\":[],\"no_sse41\":[],\"prefer_intrinsics\":[],\"pure\":[],\"rayon\":[\"dep:rayon-core\"],\"std\":[],\"traits-preview\":[\"dep:digest\"],\"wasm32_simd\":[],\"zeroize\":[\"dep:zeroize\",\"arrayvec/zeroize\"]}}", "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", "block-buffer_0.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{}}", "block-padding_0.3.3": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[]}}", @@ -713,6 +715,7 @@ "bstr_1.12.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.7.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"dfa-search\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.85\"},{\"kind\":\"dev\",\"name\":\"ucd-parse\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"unicode-segmentation\",\"req\":\"^1.2.1\"}],\"features\":{\"alloc\":[\"memchr/alloc\",\"serde?/alloc\"],\"default\":[\"std\",\"unicode\"],\"serde\":[\"dep:serde\"],\"std\":[\"alloc\",\"memchr/std\",\"serde?/std\"],\"unicode\":[\"dep:regex-automata\"]}}", "bumpalo_3.19.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.8\"},{\"kind\":\"dev\",\"name\":\"blink-alloc\",\"req\":\"=0.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"=1.10.0\"},{\"kind\":\"dev\",\"name\":\"rayon-core\",\"req\":\"=1.12.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.171\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.197\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.115\"}],\"features\":{\"allocator_api\":[],\"bench_allocator_api\":[\"allocator_api\",\"blink-alloc/nightly\"],\"boxed\":[],\"collections\":[],\"default\":[],\"serde\":[\"dep:serde\"],\"std\":[]}}", "bytemuck_1.25.0": "{\"dependencies\":[{\"name\":\"bytemuck_derive\",\"optional\":true,\"req\":\"^1.10.2\"},{\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.22\"}],\"features\":{\"aarch64_simd\":[],\"align_offset\":[],\"alloc_uninit\":[],\"avx512_simd\":[],\"const_zeroed\":[],\"derive\":[\"bytemuck_derive\"],\"extern_crate_alloc\":[],\"extern_crate_std\":[\"extern_crate_alloc\"],\"impl_core_error\":[],\"latest_stable_rust\":[\"aarch64_simd\",\"avx512_simd\",\"align_offset\",\"alloc_uninit\",\"const_zeroed\",\"derive\",\"impl_core_error\",\"min_const_generics\",\"must_cast\",\"must_cast_extra\",\"pod_saturating\",\"track_caller\",\"transparentwrapper_extra\",\"wasm_simd\",\"zeroable_atomics\",\"zeroable_maybe_uninit\",\"zeroable_unwind_fn\"],\"min_const_generics\":[],\"must_cast\":[],\"must_cast_extra\":[\"must_cast\"],\"nightly_docs\":[],\"nightly_float\":[],\"nightly_portable_simd\":[\"rustversion\"],\"nightly_stdsimd\":[],\"pod_saturating\":[],\"track_caller\":[],\"transparentwrapper_extra\":[],\"unsound_ptr_pod_impl\":[],\"wasm_simd\":[],\"zeroable_atomics\":[],\"zeroable_maybe_uninit\":[],\"zeroable_unwind_fn\":[]}}", + "bytemuck_derive_1.10.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "byteorder-lite_0.1.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "byteorder_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"std\":[]}}", "bytes-utils_0.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.144\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\",\"bytes/serde\"],\"std\":[\"bytes/default\"]}}", @@ -761,6 +764,7 @@ "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", "cmov_0.5.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.9\",\"target\":\"cfg(any(unix, windows))\"}],\"features\":{}}", "cmp_any_0.8.1": "{\"dependencies\":[],\"features\":{}}", + "cobs_0.3.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\",\"thiserror/std\"],\"use_std\":[\"std\"]}}", "codespan-reporting_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.6.3\"},{\"kind\":\"dev\",\"name\":\"peg\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rustyline\",\"req\":\"^6\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1.0.4\"},{\"name\":\"unicode-width\",\"req\":\">=0.1, <0.3\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.1\"}],\"features\":{\"ascii-only\":[],\"default\":[\"std\",\"termcolor\"],\"serialization\":[\"serde\"],\"std\":[\"serde?/std\"],\"termcolor\":[\"std\",\"dep:termcolor\"]}}", "color-eyre_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8.0\"},{\"name\":\"backtrace\",\"req\":\"^0.3.59\"},{\"name\":\"color-spantrace\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"eyre\",\"req\":\"^0.6\"},{\"name\":\"indenter\",\"req\":\"^0.3.0\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.13\"},{\"name\":\"tracing-error\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.15\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"capture-spantrace\":[\"tracing-error\",\"color-spantrace\"],\"default\":[\"track-caller\",\"capture-spantrace\"],\"issue-url\":[\"url\"],\"track-caller\":[]}}", "color-spantrace_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.29\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.21\"},{\"name\":\"tracing-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.4\"}],\"features\":{}}", @@ -812,8 +816,8 @@ "csv-core_0.1.13": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"libc\":[\"memchr/libc\"]}}", "csv_1.4.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"serde\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.7.0\"},{\"name\":\"csv-core\",\"req\":\"^0.1.11\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"}],\"features\":{}}", "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", - "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctor_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"link-section\",\"optional\":true,\"req\":\"^0.17.0\"},{\"features\":[\"ctor\"],\"name\":\"linktime-proc-macro\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2\"}],\"features\":{\"default\":[\"std\",\"proc_macro\",\"priority\"],\"priority\":[\"dep:link-section\"],\"proc_macro\":[\"dep:linktime-proc-macro\"],\"std\":[]}}", "ctutils_0.4.2": "{\"dependencies\":[{\"name\":\"cmov\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"subtle\":[\"dep:subtle\"]}}", "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", "curve25519-dalek_4.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2.6\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.2.1\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"group\":[\"dep:group\",\"rand_core\"],\"group-bits\":[\"group\",\"ff/bits\"],\"legacy_compatibility\":[],\"precomputed-tables\":[]}}", @@ -888,7 +892,8 @@ "ed25519_2.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"rand_core\"],\"kind\":\"dev\",\"name\":\"ed25519-dalek\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"signature\"],\"kind\":\"dev\",\"name\":\"ring-compat\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"serde_bytes\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"signature\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"pkcs8?/alloc\"],\"default\":[\"std\"],\"pem\":[\"alloc\",\"pkcs8/pem\"],\"serde_bytes\":[\"serde\",\"dep:serde_bytes\"],\"std\":[\"pkcs8?/std\",\"signature/std\"]}}", "either_1.15.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[],\"use_std\":[\"std\"]}}", "elliptic-curve_0.13.8": "{\"dependencies\":[{\"name\":\"base16ct\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"rand_core\",\"generic-array\",\"zeroize\"],\"name\":\"crypto-bigint\",\"req\":\"^0.5\"},{\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"generic-array\",\"req\":\"^0.14.6\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"hkdf\",\"optional\":true,\"req\":\"^0.12.1\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10.2\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"features\":[\"subtle\",\"zeroize\"],\"name\":\"sec1\",\"optional\":true,\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.47\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"tap\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.7\"}],\"features\":{\"alloc\":[\"base16ct/alloc\",\"ff?/alloc\",\"group?/alloc\",\"pkcs8?/alloc\",\"sec1?/alloc\",\"zeroize/alloc\"],\"arithmetic\":[\"group\"],\"bits\":[\"arithmetic\",\"ff/bits\",\"dep:tap\"],\"default\":[\"arithmetic\"],\"dev\":[\"arithmetic\",\"dep:hex-literal\",\"pem\",\"pkcs8\"],\"ecdh\":[\"arithmetic\",\"digest\",\"dep:hkdf\"],\"group\":[\"dep:group\",\"ff\"],\"hash2curve\":[\"arithmetic\",\"digest\"],\"hazmat\":[],\"jwk\":[\"dep:base64ct\",\"dep:serde_json\",\"alloc\",\"serde\",\"zeroize/alloc\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"arithmetic\",\"pkcs8\",\"sec1/pem\"],\"pkcs8\":[\"dep:pkcs8\",\"sec1\"],\"serde\":[\"dep:serdect\",\"alloc\",\"pkcs8\",\"sec1/serde\"],\"std\":[\"alloc\",\"rand_core/std\",\"pkcs8?/std\",\"sec1?/std\"],\"voprf\":[\"digest\"]}}", - "ena_0.14.3": "{\"dependencies\":[{\"name\":\"dogged\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"log\",\"req\":\"^0.4\"}],\"features\":{\"bench\":[],\"persistent\":[\"dogged\"]}}", + "embedded-io_0.4.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.14\"},{\"default_features\":false,\"features\":[\"net\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"async\":[],\"futures\":[\"std\",\"async\",\"dep:futures\"],\"std\":[\"alloc\",\"futures?/std\"],\"tokio\":[\"std\",\"async\",\"dep:tokio\"]}}", + "embedded-io_0.6.1": "{\"dependencies\":[{\"name\":\"defmt-03\",\"optional\":true,\"package\":\"defmt\",\"req\":\"^0.3\"}],\"features\":{\"alloc\":[],\"defmt-03\":[\"dep:defmt-03\"],\"std\":[\"alloc\"]}}", "encode_unicode_1.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"features\":[\"https-native\"],\"kind\":\"dev\",\"name\":\"minreq\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "encoding_rs_0.8.35": "{\"dependencies\":[{\"name\":\"any_all_workaround\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"fast-big5-hanzi-encode\":[],\"fast-gb-hanzi-encode\":[],\"fast-hangul-encode\":[],\"fast-hanja-encode\":[],\"fast-kanji-encode\":[],\"fast-legacy-encode\":[\"fast-hangul-encode\",\"fast-hanja-encode\",\"fast-kanji-encode\",\"fast-gb-hanzi-encode\",\"fast-big5-hanzi-encode\"],\"less-slow-big5-hanzi-encode\":[],\"less-slow-gb-hanzi-encode\":[],\"less-slow-kanji-encode\":[],\"simd-accel\":[\"any_all_workaround\"]}}", "endi_1.1.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -904,6 +909,7 @@ "env_logger_0.11.9": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"wincon\"],\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.11\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.6\"},{\"default_features\":false,\"name\":\"env_filter\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.3\"},{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.21\"}],\"features\":{\"auto-color\":[\"color\",\"anstream/auto\"],\"color\":[\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"auto-color\",\"humantime\",\"regex\"],\"humantime\":[\"dep:jiff\"],\"kv\":[\"log/kv\"],\"regex\":[\"env_filter/regex\"],\"unstable-kv\":[\"kv\"]}}", "equivalent_1.0.2": "{\"dependencies\":[],\"features\":{}}", "erased-serde_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.83\"}],\"features\":{\"alloc\":[\"serde/alloc\"],\"default\":[\"std\"],\"std\":[\"serde/std\"],\"unstable-debug\":[]}}", + "erased-serde_0.4.10": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"typeid\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"serde_core/std\"],\"unstable-debug\":[]}}", "errno_0.3.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"hermit\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"libc/std\"]}}", "error-code_3.3.2": "{\"dependencies\":[],\"features\":{\"std\":[]}}", "etcetera_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Com\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -911,6 +917,7 @@ "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", "eventsource-stream_0.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"nom\",\"req\":\"^7.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.8\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"nom/std\"]}}", "eyre_0.6.12": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.28\"},{\"kind\":\"dev\",\"name\":\"backtrace\",\"req\":\"^0.3.46\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"indenter\",\"req\":\"^0.3.0\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"default_features\":false,\"name\":\"pyo3\",\"optional\":true,\"req\":\"^0.20\"},{\"default_features\":false,\"features\":[\"auto-initialize\"],\"kind\":\"dev\",\"name\":\"pyo3\",\"req\":\"^0.20\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.19\"}],\"features\":{\"auto-install\":[],\"default\":[\"auto-install\",\"track-caller\"],\"track-caller\":[]}}", + "fancy-regex_0.16.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bit-set\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1.10\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa\",\"dfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"unicode\",\"perf\",\"std\"],\"perf\":[\"regex-automata/perf\"],\"std\":[\"regex-automata/std\",\"regex-syntax/std\",\"bit-set/std\"],\"track_caller\":[],\"unicode\":[\"regex-automata/unicode\",\"regex-syntax/unicode\"]}}", "faster-hex_0.10.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"heapless\",\"req\":\"^0.8\",\"target\":\"cfg(not(feature = \\\"alloc\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.3.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"serde\"],\"serde\":[\"dep:serde\",\"alloc\"],\"std\":[\"alloc\",\"serde?/std\"]}}", "fastrand_2.3.0": "{\"dependencies\":[{\"features\":[\"js\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wyhash\",\"req\":\"^0.5\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"js\":[\"std\",\"getrandom\"],\"std\":[\"alloc\"]}}", "fax_0.2.6": "{\"dependencies\":[{\"name\":\"fax_derive\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tiff\",\"req\":\"^0.9\"}],\"features\":{\"debug\":[]}}", @@ -932,6 +939,7 @@ "fluent-bundle_0.15.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"name\":\"fluent-syntax\",\"req\":\"^0.11.1\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"name\":\"intl-memoizer\",\"req\":\"^0.5.2\"},{\"name\":\"intl_pluralrules\",\"req\":\"^7.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rustc-hash\",\"req\":\"^1\"},{\"name\":\"self_cell\",\"req\":\"^0.10\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8\"},{\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{\"all-benchmarks\":[],\"default\":[]}}", "fluent-langneg_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-locale\",\"req\":\"^0.9\"}],\"features\":{\"cldr\":[\"unic-langid/likelysubtags\"],\"default\":[]}}", "fluent-syntax_0.11.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"}],\"features\":{\"all-benchmarks\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"]}}", + "fluent-uri_0.1.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.3.2\"}],\"features\":{\"default\":[\"std\"],\"ipv_future\":[],\"rfc6874bis\":[],\"std\":[],\"unstable\":[]}}", "fluent_0.16.1": "{\"dependencies\":[{\"name\":\"fluent-bundle\",\"req\":\"^0.15.3\"},{\"name\":\"fluent-pseudo\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", "flume_0.12.0": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\",\"js\"],\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"fastrand\"],\"select\":[],\"spin\":[]}}", "fnv_1.0.7": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -1044,14 +1052,17 @@ "h2_0.4.13": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", "h2_0.4.6": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", "half_2.7.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crunchy\",\"req\":\"^0.2.2\",\"target\":\"cfg(target_arch = \\\"spirv\\\")\"},{\"kind\":\"dev\",\"name\":\"crunchy\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.16\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_distr\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.26\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[],\"rand_distr\":[\"dep:rand\",\"dep:rand_distr\"],\"std\":[\"alloc\"],\"use-intrinsics\":[],\"zerocopy\":[]}}", + "hash32_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"byteorder\",\"req\":\"^1.2.2\"},{\"kind\":\"dev\",\"name\":\"hash32-derive\",\"req\":\"^0.1.0\"}],\"features\":{}}", "hash32_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"byteorder\",\"req\":\"^1.2.2\"}],\"features\":{}}", "hashbrown_0.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"bumpalo\",\"optional\":true,\"req\":\"^3.5.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"ahash-compile-time-rng\":[\"ahash/compile-time-rng\"],\"default\":[\"ahash\",\"inline-more\"],\"inline-more\":[],\"nightly\":[],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.14.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8.7\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.42\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7.42\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"ahash\",\"inline-more\",\"allocator-api2\"],\"inline-more\":[],\"nightly\":[\"allocator-api2?/nightly\",\"bumpalo/allocator_api\"],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.15.5": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.16.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", + "hashbrown_0.17.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", "hashlink_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", "headers-core_0.3.0": "{\"dependencies\":[{\"name\":\"http\",\"req\":\"^1.0.0\"}],\"features\":{}}", "headers_0.4.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"headers-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"httpdate\",\"req\":\"^1\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"sha1\",\"req\":\"^0.10\"}],\"features\":{\"nightly\":[]}}", + "heapless_0.7.17": "{\"dependencies\":[{\"name\":\"atomic-polyfill\",\"req\":\"^1\",\"target\":\"riscv32i-unknown-none-elf\"},{\"name\":\"atomic-polyfill\",\"req\":\"^1\",\"target\":\"riscv32imc-unknown-none-elf\"},{\"name\":\"atomic-polyfill\",\"req\":\"^1\",\"target\":\"xtensa-esp32s2-none-elf\"},{\"name\":\"atomic-polyfill\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"avr\\\")\"},{\"name\":\"atomic-polyfill\",\"optional\":true,\"req\":\"^1\",\"target\":\"thumbv6m-none-eabi\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.2.1\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spin\",\"req\":\"^0.9.2\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.1\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"__trybuild\":[],\"cas\":[\"atomic-polyfill\"],\"default\":[\"cas\"],\"defmt-impl\":[\"defmt\"],\"mpmc_large\":[],\"ufmt-impl\":[\"ufmt-write\"],\"x86-sync-pool\":[]}}", "heapless_0.8.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.3.0\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.2\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"defmt-03\":[\"dep:defmt\"],\"mpmc_large\":[],\"portable-atomic\":[\"dep:portable-atomic\"],\"portable-atomic-critical-section\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/critical-section\"],\"portable-atomic-unsafe-assume-single-core\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/unsafe-assume-single-core\"],\"serde\":[\"dep:serde\"],\"ufmt\":[\"dep:ufmt-write\"]}}", "heck_0.4.1": "{\"dependencies\":[{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.2.0\"}],\"features\":{\"default\":[],\"unicode\":[\"unicode-segmentation\"]}}", "heck_0.5.0": "{\"dependencies\":[],\"features\":{}}", @@ -1124,6 +1135,7 @@ "indenter_0.3.4": "{\"dependencies\":[],\"features\":{\"default\":[],\"std\":[]}}", "indexmap_1.9.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"rustc-rayon\",\"optional\":true,\"package\":\"rustc-rayon\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"}],\"features\":{\"serde-1\":[\"serde\"],\"std\":[],\"test_debug\":[],\"test_low_transition_point\":[]}}", "indexmap_2.13.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"req\":\"^0.16.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\",\"dep:serde\"],\"std\":[],\"test_debug\":[]}}", + "indexmap_2.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.1\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\",\"dep:serde\"],\"std\":[],\"test_debug\":[]}}", "indoc_2.0.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.2.3\"}],\"features\":{}}", "inotify-sys_0.1.5": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "inotify_0.11.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"name\":\"inotify-sys\",\"req\":\"^0.1.3\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"maplit\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"net\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.1\"}],\"features\":{\"default\":[\"stream\"],\"stream\":[\"futures-core\",\"tokio\"]}}", @@ -1132,7 +1144,7 @@ "instability_0.3.11": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"indoc\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"features\":[\"derive\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{}}", "intl-memoizer_0.5.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"intl_pluralrules\",\"req\":\"^7.0\"},{\"name\":\"type-map\",\"req\":\"^0.5\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", "intl_pluralrules_7.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", - "inventory_0.3.21": "{\"dependencies\":[{\"name\":\"rustversion\",\"req\":\"^1.0\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\"}],\"features\":{}}", + "inventory_0.3.24": "{\"dependencies\":[{\"name\":\"rustversion\",\"req\":\"^1.0\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{}}", "io-close_0.3.7": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.80\",\"target\":\"cfg(unix)\"},{\"name\":\"os_pipe\",\"optional\":true,\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"handleapi\",\"std\",\"winsock2\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "io_tee_0.1.1": "{\"dependencies\":[],\"features\":{}}", "ipconfig_0.3.2": "{\"dependencies\":[{\"name\":\"socket2\",\"req\":\"^0.5.1\",\"target\":\"cfg(windows)\"},{\"name\":\"widestring\",\"req\":\"^1.0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_Registry\"],\"name\":\"windows-sys\",\"req\":\"^0.48.0\",\"target\":\"cfg(windows)\"},{\"name\":\"winreg\",\"optional\":true,\"req\":\"^0.50.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"computer\":[\"winreg\"],\"default\":[\"computer\"]}}", @@ -1141,7 +1153,6 @@ "is-terminal_0.4.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"atty\",\"req\":\"^0.2.14\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.110\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"termios\"],\"kind\":\"dev\",\"name\":\"rustix\",\"req\":\"^1.0.0\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"stdio\"],\"kind\":\"dev\",\"name\":\"rustix\",\"req\":\"^1.0.0\",\"target\":\"cfg(not(any(windows, target_os = \\\"hermit\\\", target_os = \\\"unknown\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_Console\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "is_ci_1.2.0": "{\"dependencies\":[],\"features\":{}}", "is_terminal_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", - "itertools_0.10.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"= 0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.11.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.12.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", @@ -1167,26 +1178,26 @@ "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", "kqueue_1.1.1": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dhat\",\"req\":\"^0.3.2\"},{\"name\":\"kqueue-sys\",\"req\":\"^1.0.4\"},{\"name\":\"libc\",\"req\":\"^0.2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"}],\"features\":{}}", "kstring_2.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.4.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"arc\":[],\"default\":[\"std\",\"unsafe\"],\"max_inline\":[],\"std\":[],\"unsafe\":[],\"unstable_bench_subset\":[]}}", - "lalrpop-util_0.19.12": "{\"dependencies\":[{\"name\":\"regex\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"lexer\":[\"regex/std\",\"std\"],\"std\":[]}}", - "lalrpop_0.19.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii-canvas\",\"req\":\"^3.0\"},{\"default_features\":false,\"name\":\"bit-set\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"name\":\"diff\",\"req\":\"^0.1.12\"},{\"default_features\":false,\"name\":\"ena\",\"req\":\"^0.14\"},{\"name\":\"is-terminal\",\"req\":\"^0.4.2\"},{\"default_features\":false,\"features\":[\"use_std\"],\"name\":\"itertools\",\"req\":\"^0.10\"},{\"name\":\"lalrpop-util\",\"req\":\"^0.19.12\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"pico-args\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"string_cache\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"term\",\"req\":\"^0.7\"},{\"features\":[\"sha3\"],\"name\":\"tiny-keccak\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"default\":[\"lexer\"],\"lexer\":[\"lalrpop-util/lexer\"],\"test\":[]}}", "landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}", "language-tags_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", "lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}", "leb128fmt_0.1.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", - "libc_0.2.182": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", "libc_0.2.183": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", + "libc_0.2.186": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", "libdbus-sys_0.2.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.0.78\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"default\":[\"pkg-config\"],\"vendored\":[\"cc\"]}}", "libgit2-sys_0.18.3+1.9.2": "{\"dependencies\":[{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.43\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libssh2-sys\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(unix)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.15\"}],\"features\":{\"https\":[\"openssl-sys\"],\"ssh\":[\"libssh2-sys\"],\"vendored\":[],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\",\"libssh2-sys?/zlib-ng-compat\"]}}", "libloading_0.8.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", - "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", + "libsqlite3-sys_0.35.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "link-cplusplus_1.0.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"libc++\":[],\"libcxx\":[\"libc++\"],\"libstdc++\":[],\"libstdcxx\":[\"libstdc++\"],\"nothing\":[]}}", + "link-section_0.17.2": "{\"dependencies\":[{\"features\":[\"link_section\"],\"name\":\"linktime-proc-macro\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2\"}],\"features\":{\"default\":[\"proc_macro\",\"std\"],\"proc_macro\":[\"dep:linktime-proc-macro\"],\"std\":[]}}", "linked-hash-map_0.5.6": "{\"dependencies\":[{\"name\":\"heapsize\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"heapsize_impl\":[\"heapsize\"],\"nightly\":[],\"serde_impl\":[\"serde\"]}}", + "linktime-proc-macro_0.1.0": "{\"dependencies\":[],\"features\":{\"ctor\":[],\"default\":[],\"dtor\":[],\"link_section\":[]}}", "linux-keyutils_0.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\",\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.11\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.132\"},{\"kind\":\"dev\",\"name\":\"zeroize\",\"req\":\"^1.5.7\"}],\"features\":{\"default\":[],\"std\":[\"bitflags/std\"]}}", "linux-raw-sys_0.12.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"auxvec\":[],\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"if_tun\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"no_std\"],\"std\":[],\"system\":[],\"vm_sockets\":[],\"xdp\":[]}}", "linux-raw-sys_0.4.15": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", @@ -1194,14 +1205,16 @@ "litrs_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"optional\":true,\"req\":\"^1.0.63\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.4\"}],\"features\":{\"check_suffix\":[\"unicode-xid\"]}}", "local-waker_0.1.4": "{\"dependencies\":[],\"features\":{}}", "lock_api_0.4.14": "{\"dependencies\":[{\"name\":\"owning_ref\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"scopeguard\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.126\"}],\"features\":{\"arc_lock\":[],\"atomic_usize\":[],\"default\":[\"atomic_usize\"],\"nightly\":[]}}", + "lock_free_hashtable_0.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"allocative\",\"optional\":true,\"req\":\"^0.3.6\"},{\"name\":\"atomic\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.16\"},{\"name\":\"parking_lot\",\"req\":\"^0.12.1\"}],\"features\":{\"allocative\":[\"dep:allocative\"],\"default\":[]}}", "log_0.4.29": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.16\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.16\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.12\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", - "logos-derive_0.12.1": "{\"dependencies\":[{\"name\":\"beef\",\"req\":\"^0.5.0\"},{\"name\":\"fnv\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.9\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^1.0.17\"}],\"features\":{}}", - "logos_0.12.1": "{\"dependencies\":[{\"name\":\"logos-derive\",\"optional\":true,\"req\":\"^0.12.1\"}],\"features\":{\"default\":[\"export_derive\",\"std\"],\"export_derive\":[\"logos-derive\"],\"std\":[]}}", + "logos-codegen_0.15.1": "{\"dependencies\":[{\"name\":\"beef\",\"req\":\"^0.5.0\"},{\"name\":\"fnv\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.41.1\"},{\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.9\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"name\":\"regex-syntax\",\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.23.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.13\"}],\"features\":{\"debug\":[],\"forbid_unsafe\":[],\"fuzzing\":[]}}", + "logos-derive_0.15.1": "{\"dependencies\":[{\"name\":\"logos-codegen\",\"req\":\"^0.15.1\"}],\"features\":{\"debug\":[\"logos-codegen/debug\"],\"forbid_unsafe\":[\"logos-codegen/forbid_unsafe\"]}}", + "logos_0.15.1": "{\"dependencies\":[{\"features\":[\"auto-color\"],\"kind\":\"dev\",\"name\":\"ariadne\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chumsky\",\"req\":\"^0.10.0\"},{\"name\":\"logos-derive\",\"optional\":true,\"req\":\"^0.15.1\"}],\"features\":{\"debug\":[\"logos-derive?/debug\"],\"default\":[\"export_derive\",\"std\"],\"export_derive\":[\"logos-derive\"],\"forbid_unsafe\":[\"logos-derive?/forbid_unsafe\"],\"std\":[]}}", "loom_0.7.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"name\":\"generator\",\"req\":\"^0.8.1\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"name\":\"scoped-tls\",\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.92\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.33\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.27\"},{\"features\":[\"env-filter\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.8\"}],\"features\":{\"checkpoint\":[\"serde\",\"serde_json\"],\"default\":[],\"futures\":[\"pin-utils\"]}}", "lru-slab_0.1.2": "{\"dependencies\":[],\"features\":{}}", "lru_0.12.5": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"scoped_threadpool\",\"req\":\"0.1.*\"},{\"kind\":\"dev\",\"name\":\"stats_alloc\",\"req\":\"0.1.*\"}],\"features\":{\"default\":[\"hashbrown\"],\"nightly\":[\"hashbrown\",\"hashbrown/nightly\"]}}", "lru_0.16.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"scoped_threadpool\",\"req\":\"0.1.*\"},{\"kind\":\"dev\",\"name\":\"stats_alloc\",\"req\":\"0.1.*\"}],\"features\":{\"default\":[\"hashbrown\"],\"nightly\":[\"hashbrown\",\"hashbrown/nightly\"]}}", - "lsp-types_0.94.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.34\"},{\"name\":\"serde_json\",\"req\":\"^1.0.50\"},{\"name\":\"serde_repr\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.0.0\"}],\"features\":{\"default\":[],\"proposed\":[]}}", + "lsp-types_0.97.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.0.1\"},{\"name\":\"fluent-uri\",\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.34\"},{\"name\":\"serde_json\",\"req\":\"^1.0.50\"},{\"name\":\"serde_repr\",\"req\":\"^0.1\"}],\"features\":{\"default\":[],\"proposed\":[]}}", "lzma-rs_0.3.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.9.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"rust-lzma\",\"req\":\"^0.5\"}],\"features\":{\"enable_logging\":[\"env_logger\",\"log\"],\"raw_decoder\":[],\"stream\":[]}}", "lzma-sys_0.1.20": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.34\"},{\"name\":\"libc\",\"req\":\"^0.2.51\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.14\"}],\"features\":{\"static\":[]}}", "mach2_0.4.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"}],\"features\":{\"default\":[],\"unstable\":[]}}", @@ -1213,16 +1226,16 @@ "maybe-async_0.2.10": "{\"dependencies\":[{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit-mut\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"is_sync\":[]}}", "md-5_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "md5_0.8.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memchr_2.8.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", + "memchr_2.8.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memmap2_0.9.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.151\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"owning_ref\",\"req\":\"^0.4.1\"},{\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{}}", - "memoffset_0.6.5": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[]}}", "memoffset_0.9.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[],\"unstable_offset_of\":[]}}", "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", "mime_guess_2.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"mime\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.4.0\"},{\"kind\":\"build\",\"name\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"rev-mappings\"],\"rev-mappings\":[]}}", "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", "miniz_oxide_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"adler2\",\"req\":\"^2.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.3\"}],\"features\":{\"block-boundary\":[],\"default\":[\"with-alloc\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"adler2/rustc-dep-of-std\"],\"simd\":[\"simd-adler32\"],\"std\":[],\"with-alloc\":[]}}", "mio_1.1.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"wasi\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Wdk_System_IO\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_IO\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"log\"],\"net\":[],\"os-ext\":[\"os-poll\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_Security\"],\"os-poll\":[]}}", + "mio_1.2.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"libc\",\"req\":\"^0.2.183\",\"target\":\"cfg(any(unix, target_os = \\\"hermit\\\", target_os = \\\"wasi\\\"))\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"wasi\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Wdk_System_IO\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_IO\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"log\"],\"net\":[],\"os-ext\":[\"os-poll\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_Security\"],\"os-poll\":[]}}", "miow_0.6.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_Pipes\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <=0.61\"}],\"features\":{}}", "moka_0.12.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.8\"},{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.19\"},{\"name\":\"async-lock\",\"optional\":true,\"req\":\"^3.3\"},{\"name\":\"crossbeam-channel\",\"req\":\"^0.5.15\"},{\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.18\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"name\":\"event-listener\",\"optional\":true,\"req\":\"^5.3\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(moka_loom)\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.7\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"portable-atomic\",\"req\":\"^1.6\"},{\"name\":\"quanta\",\"optional\":true,\"req\":\"^0.12.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"features\":[\"rustls-tls\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"smallvec\",\"req\":\"^1.8\"},{\"name\":\"tagptr\",\"req\":\"^0.2\"},{\"features\":[\"fs\",\"io-util\",\"macros\",\"rt-multi-thread\",\"sync\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\",\"target\":\"cfg(trybuild)\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"req\":\"^1.1\"}],\"features\":{\"atomic64\":[],\"default\":[],\"future\":[\"async-lock\",\"event-listener\",\"futures-util\"],\"logging\":[\"log\"],\"quanta\":[\"dep:quanta\"],\"sync\":[],\"unstable-debug-counters\":[\"future\"]}}", "moxcms_0.7.11": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pxfm\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"avx\":[],\"avx512\":[],\"default\":[\"avx\",\"sse\",\"neon\"],\"neon\":[],\"options\":[],\"sse\":[]}}", @@ -1231,7 +1244,6 @@ "ndk-context_0.1.1": "{\"dependencies\":[],\"features\":{}}", "ndk-sys_0.5.0+25.2.9519653": "{\"dependencies\":[{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"}],\"features\":{\"audio\":[],\"bitmap\":[],\"media\":[],\"sync\":[],\"test\":[]}}", "ndk_0.8.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.0.0\"},{\"name\":\"ffi\",\"package\":\"ndk-sys\",\"req\":\"^0.5.0\"},{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"num_enum\",\"req\":\"^0.7\"},{\"name\":\"rwh_04\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.4\"},{\"name\":\"rwh_05\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.5\"},{\"name\":\"rwh_06\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.6\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"}],\"features\":{\"all\":[\"audio\",\"bitmap\",\"media\",\"api-level-31\",\"rwh_04\",\"rwh_05\",\"rwh_06\"],\"api-level-23\":[],\"api-level-24\":[\"api-level-23\"],\"api-level-25\":[\"api-level-24\"],\"api-level-26\":[\"api-level-25\"],\"api-level-27\":[\"api-level-26\"],\"api-level-28\":[\"api-level-27\"],\"api-level-29\":[\"api-level-28\"],\"api-level-30\":[\"api-level-29\"],\"api-level-31\":[\"api-level-30\"],\"audio\":[\"ffi/audio\",\"api-level-26\"],\"bitmap\":[\"ffi/bitmap\"],\"default\":[\"rwh_06\"],\"media\":[\"ffi/media\"],\"sync\":[\"ffi/sync\",\"api-level-26\"],\"test\":[\"ffi/test\",\"jni\",\"all\"]}}", - "new_debug_unreachable_1.0.6": "{\"dependencies\":[],\"features\":{}}", "nibble_vec_0.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"smallvec\",\"req\":\"^1.0\"}],\"features\":{}}", "nix_0.28.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.1.1\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.153\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", "nix_0.29.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.155\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", @@ -1276,7 +1288,6 @@ "oboe-sys_0.6.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fetch_unroll\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"fetch-prebuilt\":[\"fetch_unroll\"],\"generate-bindings\":[\"bindgen\"],\"shared-link\":[],\"shared-stdcxx\":[],\"test\":[]}}", "oboe_0.6.1": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"default_features\":false,\"name\":\"ndk\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"ndk-context\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"num-derive\",\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"oboe-sys\",\"req\":\"^0.6\"}],\"features\":{\"doc-cfg\":[],\"fetch-prebuilt\":[\"oboe-sys/fetch-prebuilt\"],\"generate-bindings\":[\"oboe-sys/generate-bindings\"],\"java-interface\":[\"ndk\",\"ndk-context\",\"jni\"],\"shared-link\":[\"oboe-sys/shared-link\"],\"shared-stdcxx\":[\"oboe-sys/shared-stdcxx\"]}}", "oid-registry_0.8.1": "{\"dependencies\":[{\"name\":\"asn1-rs\",\"req\":\"^0.7\"}],\"features\":{\"crypto\":[\"kdf\",\"pkcs1\",\"pkcs7\",\"pkcs9\",\"pkcs12\",\"nist_algs\",\"x962\"],\"default\":[\"registry\"],\"kdf\":[],\"ms_spc\":[],\"nist_algs\":[],\"pkcs1\":[],\"pkcs12\":[],\"pkcs7\":[],\"pkcs9\":[],\"registry\":[],\"x500\":[],\"x509\":[],\"x962\":[]}}", - "once_cell_1.21.3": "{\"dependencies\":[{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"parking_lot_core\",\"optional\":true,\"req\":\"^0.9.10\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.6\"}],\"features\":{\"alloc\":[\"race\"],\"atomic-polyfill\":[\"critical-section\"],\"critical-section\":[\"dep:critical-section\",\"portable-atomic\"],\"default\":[\"std\"],\"parking_lot\":[\"dep:parking_lot_core\"],\"portable-atomic\":[\"dep:portable-atomic\"],\"race\":[],\"std\":[\"alloc\"],\"unstable\":[]}}", "once_cell_1.21.4": "{\"dependencies\":[{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"parking_lot_core\",\"optional\":true,\"req\":\"^0.9.10\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.6\"}],\"features\":{\"alloc\":[\"race\"],\"atomic-polyfill\":[\"critical-section\"],\"critical-section\":[\"dep:critical-section\",\"portable-atomic\"],\"default\":[\"std\"],\"parking_lot\":[\"dep:parking_lot_core\"],\"portable-atomic\":[\"dep:portable-atomic\"],\"race\":[],\"std\":[\"alloc\"],\"unstable\":[]}}", "once_cell_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "onig_6.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"onig_sys\",\"req\":\"^69.9.1\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"onig_sys/generate\"],\"posix-api\":[\"onig_sys/posix-api\"],\"print-debug\":[\"onig_sys/print-debug\"],\"std-pattern\":[]}}", @@ -1303,6 +1314,8 @@ "outref_0.5.2": "{\"dependencies\":[],\"features\":{}}", "owo-colors_4.3.0": "{\"dependencies\":[{\"name\":\"supports-color\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-color-2\",\"optional\":true,\"package\":\"supports-color\",\"req\":\"^2.0\"}],\"features\":{\"alloc\":[],\"supports-colors\":[\"dep:supports-color-2\",\"supports-color\"]}}", "p256_0.13.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"der\"],\"name\":\"ecdsa-core\",\"optional\":true,\"package\":\"ecdsa\",\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"ecdsa-core\",\"package\":\"ecdsa\",\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"hazmat\",\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.13.1\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"primeorder\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"primeorder\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"alloc\":[\"ecdsa-core?/alloc\",\"elliptic-curve/alloc\"],\"arithmetic\":[\"dep:primeorder\",\"elliptic-curve/arithmetic\"],\"bits\":[\"arithmetic\",\"elliptic-curve/bits\"],\"default\":[\"arithmetic\",\"ecdsa\",\"pem\",\"std\"],\"digest\":[\"ecdsa-core/digest\",\"ecdsa-core/hazmat\"],\"ecdh\":[\"arithmetic\",\"elliptic-curve/ecdh\"],\"ecdsa\":[\"arithmetic\",\"ecdsa-core/signing\",\"ecdsa-core/verifying\",\"sha256\"],\"expose-field\":[\"arithmetic\"],\"hash2curve\":[\"arithmetic\",\"elliptic-curve/hash2curve\"],\"jwk\":[\"elliptic-curve/jwk\"],\"pem\":[\"elliptic-curve/pem\",\"ecdsa-core/pem\",\"pkcs8\"],\"pkcs8\":[\"ecdsa-core?/pkcs8\",\"elliptic-curve/pkcs8\"],\"serde\":[\"ecdsa-core?/serde\",\"elliptic-curve/serde\",\"primeorder?/serde\",\"serdect\"],\"sha256\":[\"digest\",\"sha2\"],\"std\":[\"alloc\",\"ecdsa-core?/std\",\"elliptic-curve/std\"],\"test-vectors\":[\"dep:hex-literal\"],\"voprf\":[\"elliptic-curve/voprf\",\"sha2\"]}}", + "pagable_0.4.1": "{\"dependencies\":[{\"name\":\"allocative\",\"req\":\"^0.3.6\"},{\"name\":\"anyhow\",\"req\":\"^1.0.102\"},{\"name\":\"async-trait\",\"req\":\"^0.1.86\"},{\"features\":[\"default\",\"rayon\",\"std\",\"traits-preview\"],\"name\":\"blake3\",\"req\":\"=1.8.2\"},{\"features\":[\"const_zeroed\",\"derive\",\"min_const_generics\",\"must_cast\",\"nightly_stdsimd\"],\"name\":\"bytemuck\",\"req\":\"^1.25\"},{\"name\":\"dashmap\",\"req\":\"^6.1.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.4.10\"},{\"name\":\"fancy-regex\",\"req\":\"^0.16.2\"},{\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2.14.0\"},{\"name\":\"inventory\",\"req\":\"^0.3.24\"},{\"features\":[\"serde\"],\"name\":\"num-bigint\",\"req\":\"^0.4.6\"},{\"name\":\"once_cell\",\"req\":\"^1.21.4\"},{\"name\":\"pagable_derive\",\"req\":\"=0.4.1\"},{\"features\":[\"send_guard\"],\"name\":\"parking_lot\",\"req\":\"^0.12.1\"},{\"features\":[\"use-crc\",\"use-std\"],\"name\":\"postcard\",\"req\":\"^1.0.8\"},{\"name\":\"regex\",\"req\":\"^1.12.3\"},{\"name\":\"sequence_trie\",\"req\":\"^0.3.6\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"^1.0.140\"},{\"features\":[\"const_generics\"],\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"name\":\"sorted_vector_map\",\"req\":\"^0.2\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"static_interner\",\"req\":\"^0.1.2\"},{\"name\":\"strong_hash\",\"req\":\"^0.1.0\"},{\"name\":\"take_mut\",\"req\":\"^0.2\"},{\"features\":[\"full\",\"test-util\",\"tracing\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.52.1\"},{\"features\":[\"full\",\"test-util\",\"tracing\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.52.1\"},{\"name\":\"triomphe\",\"req\":\"^0.1.11\"}],\"features\":{\"default\":[],\"tokio\":[\"dep:tokio\"]}}", + "pagable_derive_0.4.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"name\":\"quote\",\"req\":\"^1.0.45\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.117\"}],\"features\":{}}", "parking_2.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{}}", "parking_lot_0.12.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"name\":\"lock_api\",\"req\":\"^0.4.14\"},{\"name\":\"parking_lot_core\",\"req\":\"^0.9.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"}],\"features\":{\"arc_lock\":[\"lock_api/arc_lock\"],\"deadlock_detection\":[\"parking_lot_core/deadlock_detection\"],\"default\":[],\"hardware-lock-elision\":[],\"nightly\":[\"parking_lot_core/nightly\",\"lock_api/nightly\"],\"owning_ref\":[\"lock_api/owning_ref\"],\"send_guard\":[],\"serde\":[\"lock_api/serde\"]}}", "parking_lot_core_0.9.12": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.60\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.95\",\"target\":\"cfg(unix)\"},{\"name\":\"petgraph\",\"optional\":true,\"req\":\"^0.6.0\"},{\"name\":\"redox_syscall\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"name\":\"smallvec\",\"req\":\"^1.6.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"deadlock_detection\":[\"petgraph\",\"backtrace\"],\"nightly\":[]}}", @@ -1322,7 +1335,6 @@ "percent-encoding_2.3.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "petgraph_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\"],\"default\":[\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"rayon\":[\"dep:rayon\",\"indexmap/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[],\"unstable\":[\"generate\"]}}", "petgraph_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"name\":\"dot-parser\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"dot-parser-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.5.7\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\",\"dot_parser\"],\"default\":[\"std\",\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"dot_parser\":[\"std\",\"dep:dot-parser\",\"dep:dot-parser-macros\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"quickcheck\":[\"std\",\"dep:quickcheck\",\"graphmap\",\"stable_graph\"],\"rayon\":[\"std\",\"dep:rayon\",\"indexmap/rayon\",\"hashbrown/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[\"serde?/alloc\"],\"std\":[\"indexmap/std\"],\"unstable\":[\"generate\"]}}", - "phf_shared_0.11.3": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "pin-project-internal_1.1.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "pin-project-internal_1.1.11": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "pin-project-lite_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", @@ -1342,10 +1354,10 @@ "portable-atomic-util_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"req\":\"^1.5.1\"}],\"features\":{\"alloc\":[],\"default\":[],\"std\":[\"alloc\"]}}", "portable-atomic_1.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"^0.1\",\"target\":\"cfg(valgrind)\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"=0.8.16\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"=0.2.163\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"sptr\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"fallback\"],\"disable-fiq\":[],\"fallback\":[],\"float\":[],\"force-amo\":[],\"require-cas\":[],\"s-mode\":[],\"std\":[],\"unsafe-assume-privileged\":[],\"unsafe-assume-single-core\":[]}}", "portable-pty_0.9.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"bitflags\",\"req\":\"^1.3\",\"target\":\"cfg(windows)\"},{\"name\":\"downcast-rs\",\"req\":\"^1.0\"},{\"name\":\"filedescriptor\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"features\":[\"term\",\"fs\"],\"name\":\"nix\",\"req\":\"^0.28\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serial2\",\"req\":\"^0.2\"},{\"name\":\"shared_library\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"shell-words\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"smol\",\"req\":\"^2.0\"},{\"features\":[\"winuser\",\"consoleapi\",\"handleapi\",\"fileapi\",\"namedpipeapi\",\"synchapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"},{\"name\":\"winreg\",\"req\":\"^0.10\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"serde_support\":[\"serde\",\"serde_derive\"]}}", + "postcard_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"cobs\",\"req\":\"^0.3.0\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.1\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"embedded-io-04\",\"optional\":true,\"package\":\"embedded-io\",\"req\":\"^0.4\"},{\"name\":\"embedded-io-06\",\"optional\":true,\"package\":\"embedded-io\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"heapless\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"nalgebra_v0_33\",\"optional\":true,\"package\":\"nalgebra\",\"req\":\"^0.33.0\"},{\"name\":\"postcard-derive\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.100\"}],\"features\":{\"alloc\":[\"serde/alloc\",\"embedded-io-04?/alloc\",\"embedded-io-06?/alloc\"],\"core-num-saturating\":[],\"crc\":[\"dep:crc\"],\"default\":[\"heapless-cas\"],\"defmt\":[\"dep:defmt\"],\"embedded-io\":[\"dep:embedded-io-04\"],\"embedded-io-04\":[\"dep:embedded-io-04\"],\"embedded-io-06\":[\"dep:embedded-io-06\"],\"experimental-derive\":[\"postcard-derive\"],\"heapless\":[\"dep:heapless\"],\"heapless-cas\":[\"heapless\",\"dep:heapless\",\"heapless/cas\"],\"nalgebra-v0_33\":[\"nalgebra_v0_33\"],\"paste\":[],\"postcard-derive\":[\"dep:postcard-derive\"],\"use-crc\":[\"crc\"],\"use-defmt\":[\"defmt\"],\"use-std\":[\"serde/std\",\"alloc\"]}}", "potential_utf_0.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"writeable/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"writeable\":[\"dep:writeable\"],\"zerovec\":[\"dep:zerovec\"]}}", "powerfmt_0.2.0": "{\"dependencies\":[{\"name\":\"powerfmt-macros\",\"optional\":true,\"req\":\"=0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"macros\"],\"macros\":[\"dep:powerfmt-macros\"],\"std\":[\"alloc\"]}}", "ppv-lite86_0.2.21": "{\"dependencies\":[{\"features\":[\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.23\"}],\"features\":{\"default\":[\"std\"],\"no_simd\":[],\"simd\":[],\"std\":[]}}", - "precomputed-hash_0.1.1": "{\"dependencies\":[],\"features\":{}}", "predicates-core_1.0.9": "{\"dependencies\":[],\"features\":{}}", "predicates-tree_1.0.12": "{\"dependencies\":[{\"features\":[\"color\"],\"kind\":\"dev\",\"name\":\"predicates\",\"req\":\"^3.1\"},{\"name\":\"predicates-core\",\"req\":\"^1.0\"},{\"name\":\"termtree\",\"req\":\"^0.5.0\"}],\"features\":{}}", "predicates_3.1.3": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"name\":\"difflib\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"float-cmp\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"normalize-line-endings\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"predicates-core\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"predicates-tree\",\"req\":\"^1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"color\":[],\"default\":[\"diff\",\"regex\",\"float-cmp\",\"normalize-line-endings\",\"color\"],\"diff\":[\"dep:difflib\"],\"unstable\":[]}}", @@ -1381,10 +1393,10 @@ "pxfm_0.1.27": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2.3\"}],\"features\":{}}", "quick-error_2.0.1": "{\"dependencies\":[],\"features\":{}}", "quick-xml_0.38.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.8\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.139\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}", + "quickcheck_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"sys_rng\"],\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"regex\",\"use_logging\"],\"regex\":[\"env_logger/regex\"],\"use_logging\":[\"log\",\"env_logger\"]}}", "quinn-proto_0.11.14": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"__rustls-post-quantum-test\":[],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}", "quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}", "quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}", - "quote_1.0.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\"]}}", "quote_1.0.45": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\"]}}", "r-efi_5.3.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"efiapi\":[],\"examples\":[\"native\"],\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}", "r-efi_6.0.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}", @@ -1429,7 +1441,6 @@ "regex-automata_0.4.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.14\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"syntax\",\"perf\",\"unicode\",\"meta\",\"nfa\",\"dfa\",\"hybrid\"],\"dfa\":[\"dfa-build\",\"dfa-search\",\"dfa-onepass\"],\"dfa-build\":[\"nfa-thompson\",\"dfa-search\"],\"dfa-onepass\":[\"nfa-thompson\"],\"dfa-search\":[],\"hybrid\":[\"alloc\",\"nfa-thompson\"],\"internal-instrument\":[\"internal-instrument-pikevm\"],\"internal-instrument-pikevm\":[\"logging\",\"std\"],\"logging\":[\"dep:log\",\"aho-corasick?/logging\",\"memchr?/logging\"],\"meta\":[\"syntax\",\"nfa-pikevm\"],\"nfa\":[\"nfa-thompson\",\"nfa-pikevm\",\"nfa-backtrack\"],\"nfa-backtrack\":[\"nfa-thompson\"],\"nfa-pikevm\":[\"nfa-thompson\"],\"nfa-thompson\":[\"alloc\"],\"perf\":[\"perf-inline\",\"perf-literal\"],\"perf-inline\":[],\"perf-literal\":[\"perf-literal-substring\",\"perf-literal-multisubstring\"],\"perf-literal-multisubstring\":[\"dep:aho-corasick\"],\"perf-literal-substring\":[\"aho-corasick?/perf-literal\",\"dep:memchr\"],\"std\":[\"regex-syntax?/std\",\"memchr?/std\",\"aho-corasick?/std\",\"alloc\"],\"syntax\":[\"dep:regex-syntax\",\"alloc\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"unicode-word-boundary\",\"regex-syntax?/unicode\"],\"unicode-age\":[\"regex-syntax?/unicode-age\"],\"unicode-bool\":[\"regex-syntax?/unicode-bool\"],\"unicode-case\":[\"regex-syntax?/unicode-case\"],\"unicode-gencat\":[\"regex-syntax?/unicode-gencat\"],\"unicode-perl\":[\"regex-syntax?/unicode-perl\"],\"unicode-script\":[\"regex-syntax?/unicode-script\"],\"unicode-segment\":[\"regex-syntax?/unicode-segment\"],\"unicode-word-boundary\":[]}}", "regex-automata_0.4.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.14\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"syntax\",\"perf\",\"unicode\",\"meta\",\"nfa\",\"dfa\",\"hybrid\"],\"dfa\":[\"dfa-build\",\"dfa-search\",\"dfa-onepass\"],\"dfa-build\":[\"nfa-thompson\",\"dfa-search\"],\"dfa-onepass\":[\"nfa-thompson\"],\"dfa-search\":[],\"hybrid\":[\"alloc\",\"nfa-thompson\"],\"internal-instrument\":[\"internal-instrument-pikevm\"],\"internal-instrument-pikevm\":[\"logging\",\"std\"],\"logging\":[\"dep:log\",\"aho-corasick?/logging\",\"memchr?/logging\"],\"meta\":[\"syntax\",\"nfa-pikevm\"],\"nfa\":[\"nfa-thompson\",\"nfa-pikevm\",\"nfa-backtrack\"],\"nfa-backtrack\":[\"nfa-thompson\"],\"nfa-pikevm\":[\"nfa-thompson\"],\"nfa-thompson\":[\"alloc\"],\"perf\":[\"perf-inline\",\"perf-literal\"],\"perf-inline\":[],\"perf-literal\":[\"perf-literal-substring\",\"perf-literal-multisubstring\"],\"perf-literal-multisubstring\":[\"dep:aho-corasick\"],\"perf-literal-substring\":[\"aho-corasick?/perf-literal\",\"dep:memchr\"],\"std\":[\"regex-syntax?/std\",\"memchr?/std\",\"aho-corasick?/std\",\"alloc\"],\"syntax\":[\"dep:regex-syntax\",\"alloc\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"unicode-word-boundary\",\"regex-syntax?/unicode\"],\"unicode-age\":[\"regex-syntax?/unicode-age\"],\"unicode-bool\":[\"regex-syntax?/unicode-bool\"],\"unicode-case\":[\"regex-syntax?/unicode-case\"],\"unicode-gencat\":[\"regex-syntax?/unicode-gencat\"],\"unicode-perl\":[\"regex-syntax?/unicode-perl\"],\"unicode-script\":[\"regex-syntax?/unicode-script\"],\"unicode-segment\":[\"regex-syntax?/unicode-segment\"],\"unicode-word-boundary\":[]}}", "regex-lite_0.1.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"string\"],\"std\":[],\"string\":[]}}", - "regex-syntax_0.6.29": "{\"dependencies\":[],\"features\":{\"default\":[\"unicode\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex-syntax_0.8.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", @@ -1500,6 +1511,7 @@ "sentry-tracing_0.46.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.9.4\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"sentry-backtrace\",\"optional\":true,\"req\":\"^0.46.1\"},{\"features\":[\"client\"],\"name\":\"sentry-core\",\"req\":\"^0.46.1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.44\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-core\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.20\"},{\"features\":[\"fmt\",\"registry\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.20\"}],\"features\":{\"backtrace\":[\"dep:sentry-backtrace\"],\"default\":[],\"logs\":[\"sentry-core/logs\"]}}", "sentry-types_0.46.1": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"debugid\",\"req\":\"^0.8.0\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.25.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.104\"},{\"name\":\"serde_json\",\"req\":\"^1.0.46\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"},{\"features\":[\"formatting\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.5\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.1.1\"},{\"features\":[\"serde\"],\"name\":\"uuid\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"protocol\"],\"protocol\":[]}}", "sentry_0.46.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.30\"},{\"name\":\"curl\",\"optional\":true,\"req\":\"^0.4.25\"},{\"name\":\"embedded-svc\",\"optional\":true,\"req\":\"^0.28.1\"},{\"name\":\"esp-idf-svc\",\"optional\":true,\"req\":\"^0.51.0\",\"target\":\"cfg(target_os = \\\"espidf\\\")\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.8\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"blocking\",\"json\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.25\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.18\"},{\"default_features\":false,\"name\":\"sentry-actix\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-anyhow\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-backtrace\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-contexts\",\"optional\":true,\"req\":\"^0.46.1\"},{\"features\":[\"client\"],\"name\":\"sentry-core\",\"req\":\"^0.46.1\"},{\"name\":\"sentry-debug-images\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-log\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-opentelemetry\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-panic\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-slog\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-tower\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"sentry-tracing\",\"optional\":true,\"req\":\"^0.46.1\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.48\"},{\"kind\":\"dev\",\"name\":\"slog\",\"req\":\"^2.5.2\"},{\"features\":[\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.44\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.44\"},{\"features\":[\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"fmt\",\"tracing-log\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"ureq\",\"optional\":true,\"req\":\"^3.0.11\"}],\"features\":{\"actix\":[\"sentry-actix\"],\"anyhow\":[\"sentry-anyhow\"],\"backtrace\":[\"sentry-backtrace\",\"sentry-tracing?/backtrace\"],\"contexts\":[\"sentry-contexts\"],\"curl\":[\"dep:curl\",\"httpdate\"],\"debug-images\":[\"sentry-debug-images\"],\"default\":[\"backtrace\",\"contexts\",\"debug-images\",\"panic\",\"transport\",\"release-health\"],\"embedded-svc-http\":[\"dep:embedded-svc\",\"dep:esp-idf-svc\"],\"log\":[\"sentry-log\"],\"logs\":[\"sentry-core/logs\",\"sentry-tracing?/logs\",\"sentry-log?/logs\"],\"native-tls\":[\"dep:native-tls\",\"reqwest?/default-tls\",\"ureq?/native-tls\"],\"opentelemetry\":[\"sentry-opentelemetry\"],\"panic\":[\"sentry-panic\"],\"release-health\":[\"sentry-core/release-health\",\"sentry-actix?/release-health\"],\"reqwest\":[\"dep:reqwest\",\"httpdate\",\"tokio\"],\"rustls\":[\"dep:rustls\",\"reqwest?/rustls-tls\",\"ureq?/rustls\"],\"slog\":[\"sentry-slog\"],\"test\":[\"sentry-core/test\"],\"tower\":[\"sentry-tower\"],\"tower-axum-matched-path\":[\"tower-http\",\"sentry-tower/axum-matched-path\"],\"tower-http\":[\"tower\",\"sentry-tower/http\"],\"tracing\":[\"sentry-tracing\"],\"transport\":[\"reqwest\",\"native-tls\"],\"ureq\":[\"dep:ureq\",\"httpdate\"]}}", + "sequence_trie_0.3.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"btreemap\":[]}}", "serde_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"result\"],\"name\":\"serde_core\",\"req\":\"=1.0.228\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"default\":[\"std\"],\"derive\":[\"serde_derive\"],\"rc\":[\"serde_core/rc\"],\"std\":[\"serde_core/std\"],\"unstable\":[\"serde_core/unstable\"]}}", "serde_core_1.0.228": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_derive\",\"req\":\"=1.0.228\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"result\"],\"rc\":[],\"result\":[],\"std\":[],\"unstable\":[]}}", "serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}", @@ -1536,14 +1548,13 @@ "simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}", "similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}", "simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}", - "siphasher_1.0.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", "slab_0.4.12": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "smallvec_1.15.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bincode\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"bincode1\",\"package\":\"bincode\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unty\",\"optional\":true,\"req\":\"^0.0.4\"}],\"features\":{\"const_generics\":[],\"const_new\":[\"const_generics\"],\"debugger_visualizer\":[],\"drain_filter\":[],\"drain_keep_rest\":[\"drain_filter\"],\"impl_bincode\":[\"bincode\",\"unty\"],\"may_dangle\":[],\"specialization\":[],\"union\":[],\"write\":[]}}", "smawk_0.3.2": "{\"dependencies\":[{\"name\":\"ndarray\",\"optional\":true,\"req\":\"^0.15.4\"},{\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9.4\"}],\"features\":{}}", "smol_str_0.3.5": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"borsh?/std\"]}}", "socket2_0.5.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", - "socket2_0.6.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", + "sorted_vector_map_0.2.1": "{\"dependencies\":[{\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"name\":\"quickcheck\",\"req\":\"^1.0\"}],\"features\":{}}", "spin_0.9.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"name\":\"lock_api_crate\",\"optional\":true,\"package\":\"lock_api\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"barrier\":[\"mutex\"],\"default\":[\"lock_api\",\"mutex\",\"spin_mutex\",\"rwlock\",\"once\",\"lazy\",\"barrier\"],\"fair_mutex\":[\"mutex\"],\"lazy\":[\"once\"],\"lock_api\":[\"lock_api_crate\"],\"mutex\":[],\"once\":[],\"portable_atomic\":[\"portable-atomic\"],\"rwlock\":[],\"spin_mutex\":[\"mutex\"],\"std\":[],\"ticket_mutex\":[\"mutex\"],\"use_ticket_mutex\":[\"mutex\",\"ticket_mutex\"]}}", "spki_0.7.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"der/alloc\"],\"arbitrary\":[\"std\",\"dep:arbitrary\",\"der/arbitrary\"],\"base64\":[\"dep:base64ct\"],\"fingerprint\":[\"sha2\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"]}}", "sqlx-core_0.9.0": "{\"dependencies\":[{\"name\":\"async-fs\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"async-task\",\"optional\":true,\"req\":\"^4.7.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.0\"},{\"name\":\"hashlink\",\"req\":\"^0.11.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.24\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8.16\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-async-io\":[\"async-io\",\"async-fs\"],\"_rt-async-std\":[\"async-std\",\"_rt-async-io\"],\"_rt-async-task\":[\"async-task\"],\"_rt-smol\":[\"smol\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"_unstable-doc\":[\"sqlx-toml\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"],\"sqlx-toml\":[\"serde\",\"toml/parse\"]}}", @@ -1555,16 +1566,18 @@ "sqlx_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"features\":[\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.47\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[],\"_rt-async-std\":[],\"_rt-smol\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"_unstable-docs\":[\"all-databases\",\"_unstable-all-types\",\"sqlx-sqlite/_unstable-docs\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\",\"sqlx-core/offline\",\"sqlx-mysql?/offline\",\"sqlx-postgres?/offline\",\"sqlx-sqlite?/offline\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\",\"sqlx-macros?/mysql-rsa\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-global-executor\":[\"_rt-async-global-executor\",\"sqlx-core/_rt-async-global-executor\",\"sqlx-macros?/_rt-async-global-executor\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-smol\":[\"_rt-smol\",\"sqlx-core/_rt-smol\",\"sqlx-macros?/_rt-smol\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"sqlite-bundled\",\"sqlite-deserialize\",\"sqlite-load-extension\",\"sqlite-unlock-notify\"],\"sqlite-bundled\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-deserialize\":[\"sqlx-sqlite/deserialize\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\",\"sqlx-macros?/sqlite-load-extension\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"sqlite-unlock-notify\":[\"sqlx-sqlite/unlock-notify\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-macros?/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", "sse-stream_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"tracing\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"tracing\":[\"dep:tracing\"]}}", "stable_deref_trait_1.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "starlark_0.13.0": "{\"dependencies\":[{\"features\":[\"bumpalo\",\"num-bigint\"],\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"name\":\"bumpalo\",\"req\":\"^3.8\"},{\"name\":\"cmp_any\",\"req\":\"^0.8.1\"},{\"name\":\"debugserver-types\",\"req\":\"^0.5.0\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"display_container\",\"req\":\"^0.9.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.3.12\"},{\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"name\":\"inventory\",\"req\":\"^0.3.8\"},{\"name\":\"itertools\",\"req\":\"^0.13.0\"},{\"name\":\"maplit\",\"req\":\"^1.0.2\"},{\"name\":\"memoffset\",\"req\":\"^0.6.4\"},{\"name\":\"num-bigint\",\"req\":\"^0.4.3\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"name\":\"rustyline\",\"req\":\"^14.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_derive\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_syntax\",\"req\":\"^0.13.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"req\":\"^0.10.0\"},{\"name\":\"textwrap\",\"req\":\"^0.11\"},{\"name\":\"thiserror\",\"req\":\"^1.0.36\"}],\"features\":{}}", - "starlark_derive_0.13.0": "{\"dependencies\":[{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"extra-traits\",\"full\",\"visit\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "starlark_map_0.13.0": "{\"dependencies\":[{\"features\":[\"hashbrown\"],\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"name\":\"equivalent\",\"req\":\"^1.0.0\"},{\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.48\"}],\"features\":{}}", - "starlark_syntax_0.13.0": "{\"dependencies\":[{\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"annotate-snippets\",\"req\":\"^0.9.0\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"kind\":\"build\",\"name\":\"lalrpop\",\"req\":\"^0.19.7\"},{\"name\":\"lalrpop-util\",\"req\":\"^0.19.7\"},{\"name\":\"logos\",\"req\":\"^0.12\"},{\"name\":\"lsp-types\",\"req\":\"^0.94.1\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"num-bigint\",\"req\":\"^0.4.3\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.13.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.36\"}],\"features\":{}}", + "starlark_0.14.2": "{\"dependencies\":[{\"features\":[\"bumpalo\",\"num-bigint\"],\"name\":\"allocative\",\"req\":\"^0.3.6\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"features\":[\"default\",\"rayon\",\"std\",\"traits-preview\"],\"name\":\"blake3\",\"req\":\"=1.8.2\"},{\"name\":\"bumpalo\",\"req\":\"^3.8\"},{\"name\":\"cmp_any\",\"req\":\"^0.8.1\"},{\"name\":\"dashmap\",\"req\":\"^6.1.0\"},{\"name\":\"debugserver-types\",\"req\":\"^0.5.0\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"display_container\",\"req\":\"^0.9.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.3.12\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.1\"},{\"name\":\"indexmap\",\"req\":\"^2.2\"},{\"name\":\"inventory\",\"req\":\"^0.3.8\"},{\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"name\":\"maplit\",\"req\":\"^1.0.2\"},{\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"features\":[\"serde\"],\"name\":\"num-bigint\",\"req\":\"^0.4.6\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.21.4\"},{\"name\":\"pagable\",\"req\":\"^0.4.1\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"name\":\"rustyline\",\"req\":\"^14.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"arbitrary_precision\"],\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_derive\",\"req\":\"=0.14.2\"},{\"features\":[\"pagable_dep\"],\"name\":\"starlark_map\",\"req\":\"^0.14.2\"},{\"name\":\"starlark_syntax\",\"req\":\"^0.14.2\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strong_hash\",\"req\":\"^0.1.0\"},{\"name\":\"strsim\",\"req\":\"^0.10.0\"},{\"name\":\"textwrap\",\"req\":\"^0.11\"},{\"name\":\"thiserror\",\"req\":\"^2.0.18\"}],\"features\":{\"default\":[],\"pagable\":[]}}", + "starlark_derive_0.14.2": "{\"dependencies\":[{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"extra-traits\",\"full\",\"visit\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "starlark_map_0.14.2": "{\"dependencies\":[{\"features\":[\"hashbrown\"],\"name\":\"allocative\",\"req\":\"^0.3.6\"},{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"equivalent\",\"req\":\"^1.0.2\"},{\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.1\"},{\"name\":\"pagable\",\"optional\":true,\"req\":\"^0.4.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.48\"},{\"name\":\"strong_hash\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[],\"pagable_dep\":[\"dep:pagable\"]}}", + "starlark_syntax_0.14.2": "{\"dependencies\":[{\"name\":\"allocative\",\"req\":\"^0.3.6\"},{\"name\":\"annotate-snippets\",\"req\":\"^0.9.0\"},{\"name\":\"anyhow\",\"req\":\"^1.0.102\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"logos\",\"req\":\"^0.15\"},{\"name\":\"lsp-types\",\"req\":\"^0.97.0\"},{\"name\":\"memchr\",\"req\":\"^2.8.0\"},{\"features\":[\"serde\"],\"name\":\"num-bigint\",\"req\":\"^0.4.6\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.21.4\"},{\"name\":\"pagable\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.14.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.18\"}],\"features\":{}}", "static_assertions_1.1.0": "{\"dependencies\":[],\"features\":{\"nightly\":[]}}", + "static_interner_0.1.2": "{\"dependencies\":[{\"features\":[\"anyhow\",\"bumpalo\",\"dashmap\",\"either\",\"futures\",\"hashbrown\",\"indexmap\",\"num-bigint\",\"once_cell\",\"parking_lot\",\"prost-types\",\"relative-path\",\"serde_json\",\"slab\",\"smallvec\",\"compact_str\",\"sorted_vector_map\",\"tokio\",\"triomphe\"],\"name\":\"allocative\",\"optional\":true,\"req\":\"^0.3.1\"},{\"name\":\"dupe\",\"optional\":true,\"req\":\"^0.9.0\"},{\"name\":\"equivalent\",\"req\":\"^1.0.2\"},{\"name\":\"lock_free_hashtable\",\"req\":\"^0.1.0\"},{\"features\":[\"num-bigint\",\"triomphe\"],\"name\":\"strong_hash\",\"optional\":true,\"req\":\"^0.1.0\"}],\"features\":{\"allocative\":[\"dep:allocative\"],\"default\":[],\"dupe\":[\"dep:dupe\"],\"strong_hash\":[\"dep:strong_hash\"]}}", "stop-words_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"human_regex\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"constructed\":[],\"default\":[\"iso\"],\"iso\":[],\"nltk\":[],\"unimplemented\":[]}}", "strck_1.0.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smol_str\",\"req\":\"^0.3\"},{\"name\":\"unicode-ident\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"ident\":[\"dep:unicode-ident\"]}}", "streaming-iterator_0.1.9": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", - "string_cache_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"new_debug_unreachable\",\"req\":\"^1.0.2\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"phf_shared\",\"req\":\"^0.11\"},{\"name\":\"precomputed-hash\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"serde_support\"],\"serde_support\":[\"serde\"]}}", "stringprep_0.1.5": "{\"dependencies\":[{\"name\":\"unicode-bidi\",\"req\":\"^0.3\"},{\"name\":\"unicode-normalization\",\"req\":\"^0.1\"},{\"name\":\"unicode-properties\",\"req\":\"^0.1.1\"}],\"features\":{}}", + "strong_hash_0.1.0": "{\"dependencies\":[{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"strong_hash_derive\",\"req\":\"^0.1.0\"},{\"name\":\"triomphe\",\"optional\":true,\"req\":\"^0.1.8\"}],\"features\":{}}", + "strong_hash_derive_0.1.0": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"extra-traits\",\"full\",\"visit\"],\"name\":\"syn\",\"req\":\"^2.0.110\"}],\"features\":{}}", "strsim_0.10.0": "{\"dependencies\":[],\"features\":{}}", "strsim_0.11.1": "{\"dependencies\":[],\"features\":{}}", "strum_0.26.3": "{\"dependencies\":[{\"features\":[\"macros\"],\"name\":\"phf\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"strum_macros\",\"optional\":true,\"req\":\"^0.26.3\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"}],\"features\":{\"default\":[\"std\"],\"derive\":[\"strum_macros\"],\"std\":[]}}", @@ -1576,7 +1589,6 @@ "supports-color_2.1.0": "{\"dependencies\":[{\"name\":\"is-terminal\",\"req\":\"^0.4.0\"},{\"name\":\"is_ci\",\"req\":\"^1.1.1\"}],\"features\":{}}", "supports-color_3.0.2": "{\"dependencies\":[{\"name\":\"is_ci\",\"req\":\"^1.2.0\"}],\"features\":{}}", "syn_1.0.109": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.46\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.1\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", - "syn_2.0.114": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", "syn_2.0.117": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", "sync_wrapper_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"}],\"features\":{\"futures\":[\"futures-core\"]}}", "synstructure_0.13.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"visit\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"synstructure_test_traits\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"syn/proc-macro\",\"quote/proc-macro\"]}}", @@ -1586,6 +1598,7 @@ "system-configuration_0.7.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "system-deps_7.0.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5\"},{\"features\":[\"targets\"],\"name\":\"cfg-expr\",\"req\":\">=0.17, <0.21\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"pkg-config\",\"req\":\"^0.3.25\"},{\"default_features\":false,\"features\":[\"parse\",\"std\"],\"name\":\"toml\",\"req\":\"^0.9\"},{\"name\":\"version-compare\",\"req\":\"^0.2\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", + "take_mut_0.2.2": "{\"dependencies\":[],\"features\":{}}", "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "target-lexicon_0.13.3": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", @@ -1614,7 +1627,6 @@ "time-macros_0.2.27": "{\"dependencies\":[{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"}],\"features\":{\"formatting\":[],\"large-dates\":[],\"parsing\":[],\"serde\":[]}}", "time_0.3.47": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\",\"target\":\"cfg(bench)\"},{\"features\":[\"powerfmt\"],\"name\":\"deranged\",\"req\":\"^0.5.2\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"num_threads\",\"optional\":true,\"req\":\"^0.1.2\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"default_features\":false,\"name\":\"powerfmt\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.126\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"},{\"name\":\"time-macros\",\"optional\":true,\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"time-macros\",\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.102\",\"target\":\"cfg(__ui_tests)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"formatting\":[\"dep:itoa\",\"std\",\"time-macros?/formatting\"],\"large-dates\":[\"time-core/large-dates\",\"time-macros?/large-dates\"],\"local-offset\":[\"std\",\"dep:libc\",\"dep:num_threads\"],\"macros\":[\"dep:time-macros\"],\"parsing\":[\"time-macros?/parsing\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\",\"deranged/quickcheck\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\",\"deranged/rand08\"],\"rand09\":[\"dep:rand09\",\"deranged/rand09\"],\"serde\":[\"dep:serde_core\",\"time-macros?/serde\",\"deranged/serde\"],\"serde-human-readable\":[\"serde\",\"formatting\",\"parsing\"],\"serde-well-known\":[\"serde\",\"formatting\",\"parsing\"],\"std\":[\"alloc\"],\"wasm-bindgen\":[\"dep:js-sys\"]}}", "timezone_provider_0.2.3": "{\"dependencies\":[{\"name\":\"combine\",\"optional\":true,\"req\":\"^4.6.7\"},{\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"compiled_data\"],\"kind\":\"dev\",\"name\":\"icu_time\",\"req\":\"^2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.225\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"tzif\",\"optional\":true,\"req\":\"^0.4.0\"},{\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\",\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\",\"unstable\"],\"name\":\"zoneinfo_rs\",\"optional\":true,\"req\":\"^0.1.0\"}],\"features\":{\"datagen\":[\"std\",\"dep:serde\",\"dep:databake\",\"dep:yoke\",\"dep:serde_json\",\"tinystr/serde\",\"tinystr/databake\",\"zerotrie/serde\",\"zerotrie/databake\",\"zerovec/serde\",\"zerovec/databake\",\"zerovec/derive\",\"dep:zoneinfo_rs\",\"experimental_tzif\"],\"default\":[],\"experimental_tzif\":[\"dep:zerofrom\",\"zerofrom/derive\"],\"std\":[],\"tzif\":[\"dep:tzif\",\"dep:jiff-tzdb\",\"dep:combine\",\"std\"],\"zoneinfo64\":[\"dep:zoneinfo64\"]}}", - "tiny-keccak_2.0.2": "{\"dependencies\":[{\"name\":\"crunchy\",\"req\":\"^0.2.2\"}],\"features\":{\"cshake\":[],\"default\":[],\"fips202\":[\"keccak\",\"shake\",\"sha3\"],\"k12\":[],\"keccak\":[],\"kmac\":[\"cshake\"],\"parallel_hash\":[\"cshake\"],\"sha3\":[],\"shake\":[],\"sp800\":[\"cshake\",\"kmac\",\"tuple_hash\"],\"tuple_hash\":[\"cshake\"]}}", "tiny_http_0.12.0": "{\"dependencies\":[{\"name\":\"ascii\",\"req\":\"^1.0\"},{\"name\":\"chunked_transfer\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fdlimit\",\"req\":\"^0.1\"},{\"name\":\"httpdate\",\"req\":\"^1.0.2\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rustc-serialize\",\"req\":\"^0.3\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.6.0\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"ssl\":[\"ssl-openssl\"],\"ssl-openssl\":[\"openssl\",\"zeroize\"],\"ssl-rustls\":[\"rustls\",\"rustls-pemfile\",\"zeroize\"]}}", "tinystr_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", "tinystr_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", @@ -1622,7 +1634,7 @@ "tinyvec_macros_0.1.1": "{\"dependencies\":[],\"features\":{}}", "tokio-graceful_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\",\"target\":\"cfg(not(loom))\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"server\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"server\",\"server-auto\",\"http1\",\"http2\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"futures\",\"checkpoint\"],\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"slab\",\"req\":\"^0.4\"},{\"features\":[\"rt\",\"signal\",\"sync\",\"macros\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"net\",\"rt-multi-thread\",\"io-util\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{}}", "tokio-macros_2.4.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"}],\"features\":{}}", - "tokio-macros_2.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"}],\"features\":{}}", + "tokio-macros_2.7.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"}],\"features\":{}}", "tokio-native-tls_0.3.1": "{\"dependencies\":[{\"name\":\"native-tls\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.6\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"io-util\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\",\"target\":\"cfg(all(not(target_os = \\\"macos\\\"), not(windows), not(target_os = \\\"ios\\\")))\"},{\"kind\":\"dev\",\"name\":\"security-framework\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"kind\":\"dev\",\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"features\":[\"lmcons\",\"basetsd\",\"minwinbase\",\"minwindef\",\"ntdef\",\"sysinfoapi\",\"timezoneapi\",\"wincrypt\",\"winerror\"],\"kind\":\"dev\",\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{\"vendored\":[\"native-tls/vendored\"]}}", "tokio-rustls_0.26.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"brotli\":[\"rustls/brotli\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"],\"zlib\":[\"rustls/zlib\"]}}", "tokio-stream_0.1.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", @@ -1631,7 +1643,7 @@ "tokio-util_0.7.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", "tokio_1.39.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"req\":\"^0.3.58\",\"target\":\"cfg(tokio_taskdump)\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.0.0\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.149\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.149\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^0.9.0\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.11.1\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.5.5\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.4.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.25\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.52\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", - "tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", + "tokio_1.52.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"kind\":\"dev\",\"name\":\"backtrace\",\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.11\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.2.0\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.31.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.3\",\"target\":\"cfg(any(not(target_family = \\\"wasm\\\"), all(target_os = \\\"wasi\\\", not(target_env = \\\"p1\\\"))))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.7.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", "toml_0.9.11+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.11.4\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.145\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.3\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.13\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde_core\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde_core?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", "toml_0.9.12+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.11.4\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.145\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.3\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.13\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde_core\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde_core?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", @@ -1667,11 +1679,13 @@ "tree-sitter-language_0.1.7": "{\"dependencies\":[],\"features\":{}}", "tree-sitter_0.25.10": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71.1\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.10\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"features\":[\"preserve_order\"],\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1.0.137\"},{\"name\":\"streaming-iterator\",\"req\":\"^0.1.9\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"cranelift\",\"gc-drc\"],\"name\":\"wasmtime-c-api\",\"optional\":true,\"package\":\"wasmtime-c-api-impl\",\"req\":\"^29.0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"regex/std\",\"regex/perf\",\"regex-syntax/unicode\"],\"wasm\":[\"std\",\"wasmtime-c-api\"]}}", "tree_magic_mini_3.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.0\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"name\":\"nom\",\"req\":\"^8.0\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.8.0\"},{\"name\":\"tree_magic_db\",\"optional\":true,\"req\":\"^3.0\"}],\"features\":{\"with-gpl-data\":[\"dep:tree_magic_db\"]}}", + "triomphe_0.1.15": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1.3.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.1\"},{\"name\":\"unsize\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"default\":[\"serde\",\"stable_deref_trait\",\"std\"],\"std\":[],\"unstable_dropck_eyepatch\":[]}}", "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", + "typeid_1.0.3": "{\"dependencies\":[],\"features\":{}}", "typenum_1.20.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"i128\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index 9f029ada1d..6d963b725d 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -1,9 +1,10 @@ [advisories] -# Reviewed 2026-04-15. Keep this list in sync with ../deny.toml. +# Reviewed 2026-06-07. Keep this list in sync with ../deny.toml. ignore = [ - "RUSTSEC-2024-0388", # derivative 2.2.0 via starlark; upstream crate is unmaintained - "RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map; upstream crate is unmaintained + "RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained + "RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained "RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained + "RUSTSEC-2023-0089", # atomic-polyfill via postcard/heapless/pagable; upstream crate is unmaintained "RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it "RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it "RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ded51c95b2..ec5bfebf7b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -154,7 +154,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.2", + "socket2 0.6.3", "time", "tracing", "url", @@ -263,26 +263,26 @@ dependencies = [ [[package]] name = "allocative" -version = "0.3.4" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fac2ce611db8b8cee9b2aa886ca03c924e9da5e5295d0dbd0526e5d0b0710f7" +checksum = "d8cf9afc79c83d514444b55df3935d317da54b1ce3b17a133c646889cc260de8" dependencies = [ "allocative_derive", "bumpalo", - "ctor 0.1.26", - "hashbrown 0.14.5", + "ctor 1.0.6", + "hashbrown 0.16.1", "num-bigint", ] [[package]] name = "allocative_derive" -version = "0.3.3" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" +checksum = "614043c56c1173b800acb007b81fd0cbc0a0d7d717b71ba705fc2230d0760a23" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -396,9 +396,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app_test_support" @@ -463,6 +463,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -475,15 +481,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -[[package]] -name = "ascii-canvas" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" -dependencies = [ - "term", -] - [[package]] name = "asn1-rs" version = "0.7.1" @@ -508,7 +505,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -520,7 +517,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -658,7 +655,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -698,7 +695,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -715,7 +712,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -737,6 +734,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1089,7 +1101,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1285,23 +1297,23 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "bit-set" -version = "0.5.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.6.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" @@ -1327,6 +1339,21 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.3.1", + "digest 0.10.7", + "rayon-core", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1421,6 +1448,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "byteorder" @@ -1512,7 +1553,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1739,7 +1780,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1787,6 +1828,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -2467,7 +2517,7 @@ dependencies = [ "dunce", "futures", "gethostname", - "indexmap 2.13.0", + "indexmap 2.14.0", "libc", "multimap", "pretty_assertions", @@ -2597,7 +2647,7 @@ dependencies = [ "http 1.4.0", "iana-time-zone", "image", - "indexmap 2.13.0", + "indexmap 2.14.0", "insta", "libc", "maplit", @@ -2680,7 +2730,7 @@ dependencies = [ "codex-utils-plugins", "dirs", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.0", "libc", "pretty_assertions", "reqwest 0.12.28", @@ -2861,7 +2911,7 @@ version = "0.0.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3721,7 +3771,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "socket2 0.6.2", + "socket2 0.6.3", "tempfile", "tokio", "tokio-util", @@ -4768,16 +4818,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "ctor" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "ctor" version = "0.6.3" @@ -4788,6 +4828,16 @@ dependencies = [ "dtor", ] +[[package]] +name = "ctor" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "ctor-proc-macro" version = "0.0.7" @@ -4827,7 +4877,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4853,11 +4903,11 @@ checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", "scratch", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4868,10 +4918,10 @@ checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4886,10 +4936,10 @@ version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4933,7 +4983,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4947,7 +4997,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4960,7 +5010,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4971,7 +5021,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4982,7 +5032,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4993,7 +5043,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5156,7 +5206,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5186,7 +5236,7 @@ dependencies = [ "convert_case 0.6.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -5200,7 +5250,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -5263,7 +5313,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5283,7 +5333,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5356,7 +5406,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5381,7 +5431,7 @@ checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5392,7 +5442,7 @@ checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" dependencies = [ "cfg-if", "libc", - "socket2 0.6.2", + "socket2 0.6.3", "windows-sys 0.60.2", ] @@ -5455,7 +5505,7 @@ checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5532,13 +5582,16 @@ dependencies = [ ] [[package]] -name = "ena" -version = "0.14.3" +name = "embedded-io" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" -dependencies = [ - "log", -] +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "encode_unicode" @@ -5582,7 +5635,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5603,7 +5656,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5650,6 +5703,17 @@ dependencies = [ "serde", ] +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -5718,13 +5782,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" dependencies = [ - "heapless", + "heapless 0.8.0", "serde", ] @@ -5754,7 +5829,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5929,6 +6004,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "flume" version = "0.12.0" @@ -6098,7 +6182,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7140,7 +7224,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7169,7 +7253,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax", ] [[package]] @@ -7215,7 +7299,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -7233,6 +7317,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -7253,10 +7346,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -7280,6 +7369,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.11.0" @@ -7313,13 +7408,27 @@ dependencies = [ "http 1.4.0", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", + "hash32 0.3.1", "stable_deref_trait", ] @@ -7618,7 +7727,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -7675,7 +7784,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", "unic-langid", ] @@ -7689,7 +7798,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8027,12 +8136,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -8098,7 +8207,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8122,9 +8231,9 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] @@ -8196,15 +8305,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.11.0" @@ -8267,7 +8367,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8328,7 +8428,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8353,7 +8453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8444,37 +8544,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "lalrpop" -version = "0.19.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" -dependencies = [ - "ascii-canvas", - "bit-set", - "diff", - "ena", - "is-terminal", - "itertools 0.10.5", - "lalrpop-util", - "petgraph 0.6.5", - "regex", - "regex-syntax 0.6.29", - "string_cache", - "term", - "tiny-keccak", - "unicode-xid", -] - -[[package]] -name = "lalrpop-util" -version = "0.19.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3c48237b9604c5a4702de6b824e02006c3214327564636aef27c1028a8fa0ed" -dependencies = [ - "regex", -] - [[package]] name = "landlock" version = "0.4.4" @@ -8506,9 +8575,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -8548,9 +8617,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ "cc", "pkg-config", @@ -8602,12 +8671,24 @@ dependencies = [ "cc", ] +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -8682,6 +8763,16 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lock_free_hashtable" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf3631712f5b790675292ff827af269f5d9f920c920b77dc41d0485e3719612" +dependencies = [ + "atomic", + "parking_lot", +] + [[package]] name = "log" version = "0.4.29" @@ -8690,25 +8781,36 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logos" -version = "0.12.1" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf8b031682c67a8e3d5446840f9573eb7fe26efe7ec8d195c9ac4c0647c502f1" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" dependencies = [ "logos-derive", ] [[package]] -name = "logos-derive" -version = "0.12.1" +name = "logos-codegen" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d849148dbaf9661a6151d1ca82b13bb4c4c128146a88d05253b38d4e2f496c" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" dependencies = [ "beef", "fnv", + "lazy_static", "proc-macro2", "quote", - "regex-syntax 0.6.29", - "syn 1.0.109", + "regex-syntax", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen", ] [[package]] @@ -8753,15 +8855,15 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lsp-types" -version = "0.94.1" +version = "0.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" dependencies = [ "bitflags 1.3.2", + "fluent-uri", "serde", "serde_json", "serde_repr", - "url", ] [[package]] @@ -8829,7 +8931,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8870,9 +8972,9 @@ checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -8883,15 +8985,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -8935,9 +9028,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -9027,12 +9120,6 @@ dependencies = [ "jni-sys 0.3.0", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nibble_vec" version = "0.1.0" @@ -9064,7 +9151,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -9187,6 +9274,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -9212,7 +9300,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9284,7 +9372,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9530,9 +9618,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ "critical-section", "portable-atomic", @@ -9595,7 +9683,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9801,6 +9889,53 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pagable" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3658968938a4d1eaa1987e69dcd84b01fb067c5b3416dccc8d71373b6ded6821" +dependencies = [ + "allocative", + "anyhow", + "async-trait", + "blake3", + "bytemuck", + "dashmap", + "dupe", + "either", + "erased-serde 0.4.10", + "fancy-regex", + "indexmap 2.14.0", + "inventory", + "num-bigint", + "once_cell", + "pagable_derive", + "parking_lot", + "postcard", + "regex", + "sequence_trie", + "serde", + "serde_json", + "smallvec", + "sorted_vector_map", + "static_assertions", + "static_interner", + "strong_hash", + "take_mut", + "triomphe", +] + +[[package]] +name = "pagable_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838d17166587914f4e99353766c29160462b681511f08679545a0d07a0dc9415" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -9968,7 +10103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.13.0", + "indexmap 2.14.0", ] [[package]] @@ -9979,16 +10114,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap 2.13.0", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", + "indexmap 2.14.0", ] [[package]] @@ -10008,7 +10134,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10057,7 +10183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "quick-xml", "serde", "time", @@ -10137,6 +10263,20 @@ dependencies = [ "winreg 0.10.1", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "crc", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -10163,12 +10303,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "predicates" version = "3.1.3" @@ -10216,7 +10350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10256,7 +10390,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10275,7 +10409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60" dependencies = [ "futures", - "indexmap 2.13.0", + "indexmap 2.14.0", "nix 0.30.1", "tokio", "tracing", @@ -10302,7 +10436,7 @@ dependencies = [ "rand 0.9.3", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.8", + "regex-syntax", "unarray", ] @@ -10333,7 +10467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.4.1", + "heck 0.5.0", "itertools 0.11.0", "log", "multimap", @@ -10343,7 +10477,7 @@ dependencies = [ "prost 0.12.6", "prost-types 0.12.6", "regex", - "syn 2.0.114", + "syn 2.0.117", "tempfile", ] @@ -10353,7 +10487,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -10362,7 +10496,7 @@ dependencies = [ "prost 0.14.3", "prost-types 0.14.3", "regex", - "syn 2.0.114", + "syn 2.0.117", "tempfile", ] @@ -10376,7 +10510,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10389,7 +10523,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10479,6 +10613,17 @@ dependencies = [ "serde", ] +[[package]] +name = "quickcheck" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" +dependencies = [ + "env_logger", + "log", + "rand 0.10.1", +] + [[package]] name = "quinn" version = "0.11.9" @@ -10492,7 +10637,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -10530,16 +10675,16 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -10686,7 +10831,7 @@ dependencies = [ "futures-channel", "httparse", "httpdate", - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "parking_lot", "pin-project-lite", @@ -10761,7 +10906,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10788,7 +10933,7 @@ dependencies = [ "rama-utils", "serde", "sha2 0.10.9", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", ] @@ -11091,7 +11236,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11103,7 +11248,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax", ] [[package]] @@ -11114,7 +11259,7 @@ checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.8", + "regex-syntax", ] [[package]] @@ -11123,12 +11268,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.8" @@ -11311,7 +11450,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11345,7 +11484,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.114", + "syn 2.0.117", "walkdir", ] @@ -11487,7 +11626,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11667,7 +11806,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11679,7 +11818,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11946,6 +12085,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "sequence_trie" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee22067b7ccd072eeb64454b9c6e1b33b61cd0d49e895fd48676a184580e0c3" + [[package]] name = "serde" version = "1.0.228" @@ -11973,7 +12118,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11984,7 +12129,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11994,7 +12139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" dependencies = [ "form_urlencoded", - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde_core", @@ -12016,7 +12161,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -12043,7 +12188,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12077,7 +12222,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -12095,7 +12240,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12104,7 +12249,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -12145,7 +12290,7 @@ checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12320,12 +12465,6 @@ dependencies = [ "time", ] -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" @@ -12369,12 +12508,22 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted_vector_map" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bf565ee1681b4473aa5a9d71d807347c28021bd1d8947cb626b02f42a0141f" +dependencies = [ + "itertools 0.14.0", + "quickcheck", ] [[package]] @@ -12429,7 +12578,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "memchr", "percent-encoding", @@ -12458,7 +12607,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12481,7 +12630,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.114", + "syn 2.0.117", "thiserror 2.0.18", "tokio", "url", @@ -12602,29 +12751,33 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "starlark" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f53849859f05d9db705b221bd92eede93877fd426c1b4a3c3061403a5912a8f" +checksum = "9062e866918dc4c9701c98ac99f7f4fa9e4b3b4edce306e147393bc75458c4fc" dependencies = [ "allocative", "anyhow", + "blake3", "bumpalo", "cmp_any", + "dashmap", "debugserver-types", "derivative", "derive_more 1.0.0", "display_container", "dupe", "either", - "erased-serde", - "hashbrown 0.14.5", + "erased-serde 0.3.31", + "hashbrown 0.16.1", + "indexmap 2.14.0", "inventory", - "itertools 0.13.0", + "itertools 0.14.0", "maplit", - "memoffset 0.6.5", + "memoffset", "num-bigint", "num-traits", "once_cell", + "pagable", "paste", "ref-cast", "regex", @@ -12635,42 +12788,45 @@ dependencies = [ "starlark_map", "starlark_syntax", "static_assertions", + "strong_hash", "strsim 0.10.0", "textwrap 0.11.0", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "starlark_derive" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe58bc6c8b7980a1fe4c9f8f48200c3212db42ebfe21ae6a0336385ab53f082a" +checksum = "797e235eb70936bfa14fabf490bf7453e6f0caaf6b9c56fe4c9aff02aee7e66d" dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "starlark_map" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92659970f120df0cc1c0bb220b33587b7a9a90e80d4eecc5c5af5debb950173d" +checksum = "234877898fd216af93b2f5798b08cbbdc1a2e8f16a622a258b1db23a61a1c4ba" dependencies = [ "allocative", "dupe", "equivalent", "fxhash", - "hashbrown 0.14.5", + "hashbrown 0.16.1", + "pagable", "serde", + "strong_hash", ] [[package]] name = "starlark_syntax" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe53b3690d776aafd7cb6b9fed62d94f83280e3b87d88e3719cc0024638461b3" +checksum = "7492c571c531e68099c911cfd909d32659f1cc0910cf3adee9fce66e39d21f14" dependencies = [ "allocative", "annotate-snippets", @@ -12678,16 +12834,15 @@ dependencies = [ "derivative", "derive_more 1.0.0", "dupe", - "lalrpop", - "lalrpop-util", "logos", "lsp-types", "memchr", "num-bigint", "num-traits", "once_cell", + "pagable", "starlark_map", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -12696,6 +12851,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_interner" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab44341fbf4deae6e8d5ab450f24e1b34e0b2439d39ac0e9b5215a4e5493263" +dependencies = [ + "equivalent", + "lock_free_hashtable", +] + [[package]] name = "stop-words" version = "0.9.0" @@ -12720,18 +12885,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", -] - [[package]] name = "stringprep" version = "0.1.5" @@ -12743,6 +12896,26 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strong_hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0831334aea34390b6b6ec7af0a27f9ee6324ad3a69463e6b240d83d6b7bce9c9" +dependencies = [ + "ref-cast", + "strong_hash_derive", +] + +[[package]] +name = "strong_hash_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace6b48b7c4383a39bd3b966cca41bc999003aab9f690a2f355525c924296928" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "strsim" version = "0.10.0" @@ -12783,7 +12956,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12795,7 +12968,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12807,7 +12980,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12848,9 +13021,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -12874,7 +13047,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12889,7 +13062,7 @@ dependencies = [ "once_cell", "onig", "plist", - "regex-syntax 0.8.8", + "regex-syntax", "serde", "serde_derive", "serde_json", @@ -12947,6 +13120,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + [[package]] name = "tar" version = "0.4.45" @@ -13010,17 +13189,6 @@ dependencies = [ "writeable", ] -[[package]] -name = "term" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" -dependencies = [ - "dirs-next", - "rustversion", - "winapi", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -13064,7 +13232,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13075,7 +13243,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "test-case-core", ] @@ -13098,7 +13266,7 @@ checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13147,7 +13315,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13158,7 +13326,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13229,15 +13397,6 @@ dependencies = [ "zoneinfo64", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tiny_http" version = "0.12.0" @@ -13278,9 +13437,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -13288,7 +13447,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -13308,13 +13467,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13406,7 +13565,7 @@ version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -13430,7 +13589,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -13442,7 +13601,7 @@ version = "0.24.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "toml_writer", @@ -13484,7 +13643,7 @@ dependencies = [ "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.2", + "socket2 0.6.3", "sync_wrapper", "tokio", "tokio-rustls", @@ -13504,7 +13663,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13529,7 +13688,7 @@ dependencies = [ "prost-build 0.14.3", "prost-types 0.14.3", "quote", - "syn 2.0.114", + "syn 2.0.117", "tempfile", "tonic-build", ] @@ -13542,7 +13701,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -13615,7 +13774,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13714,7 +13873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -13725,7 +13884,7 @@ checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", - "regex-syntax 0.8.8", + "regex-syntax", "serde_json", "streaming-iterator", "tree-sitter-language", @@ -13758,6 +13917,16 @@ dependencies = [ "petgraph 0.8.3", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -13784,7 +13953,7 @@ checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "termcolor", ] @@ -13828,6 +13997,12 @@ dependencies = [ "rustc-hash 2.1.1", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.0" @@ -13840,7 +14015,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset 0.9.1", + "memoffset", "tempfile", "winapi", ] @@ -14238,7 +14413,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -14268,7 +14443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -14307,7 +14482,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -14657,7 +14832,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -14668,7 +14843,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -14679,7 +14854,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -14690,7 +14865,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -15163,9 +15338,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", - "syn 2.0.114", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -15181,7 +15356,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -15194,7 +15369,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -15213,7 +15388,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -15363,7 +15538,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -15414,7 +15589,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -15446,7 +15621,7 @@ checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -15466,7 +15641,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -15487,7 +15662,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -15522,7 +15697,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -15562,7 +15737,7 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac 0.12.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "lzma-rs", "memchr", "pbkdf2 0.12.2", @@ -15711,7 +15886,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -15723,5 +15898,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9d870e29be..0e3aba3288 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -384,7 +384,7 @@ sqlx = { version = "0.9.0", default-features = false, features = [ "time", "uuid", ] } -starlark = "0.13.0" +starlark = { version = "0.14.2", default-features = false } strum = "0.27.2" strum_macros = "0.28.0" supports-color = "3.0.2" diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index ae710a6607..a5c06e862e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -70,11 +70,12 @@ feature-depth = 1 # A list of advisory IDs to ignore. Note that ignored advisories will still # output a note when they are encountered. ignore = [ - # Reviewed 2026-04-15. Keep this list in sync with .cargo/audit.toml. + # Reviewed 2026-06-07. Keep this list in sync with .cargo/audit.toml. # Each exception must identify the dependency path and removal condition. - { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, - { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, + { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark/starlark_syntax v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" }, + { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map under starlark v0.14.2 used by execpolicy/cli/core and bm25 used by core; remove when upstream dependencies drop fxhash" }, { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, + { id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is unmaintained; pulled in via postcard/heapless/pagable under starlark v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" }, # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, diff --git a/codex-rs/execpolicy-legacy/src/arg_matcher.rs b/codex-rs/execpolicy-legacy/src/arg_matcher.rs index 3d413fe7ff..f8248d20ec 100644 --- a/codex-rs/execpolicy-legacy/src/arg_matcher.rs +++ b/codex-rs/execpolicy-legacy/src/arg_matcher.rs @@ -95,7 +95,7 @@ impl ArgMatcherCardinality { } impl<'v> AllocValue<'v> for ArgMatcher { - fn alloc_value(self, heap: &'v Heap) -> Value<'v> { + fn alloc_value(self, heap: Heap<'v>) -> Value<'v> { heap.alloc_simple(self) } } diff --git a/codex-rs/execpolicy-legacy/src/opt.rs b/codex-rs/execpolicy-legacy/src/opt.rs index 2325d99804..06ed3ae42d 100644 --- a/codex-rs/execpolicy-legacy/src/opt.rs +++ b/codex-rs/execpolicy-legacy/src/opt.rs @@ -66,7 +66,7 @@ impl<'v> UnpackValue<'v> for Opt { } impl<'v> AllocValue<'v> for Opt { - fn alloc_value(self, heap: &'v Heap) -> Value<'v> { + fn alloc_value(self, heap: Heap<'v>) -> Value<'v> { heap.alloc_simple(self) } } diff --git a/codex-rs/execpolicy-legacy/src/policy_parser.rs b/codex-rs/execpolicy-legacy/src/policy_parser.rs index 2580e5b679..3e54872cf9 100644 --- a/codex-rs/execpolicy-legacy/src/policy_parser.rs +++ b/codex-rs/execpolicy-legacy/src/policy_parser.rs @@ -15,7 +15,6 @@ use starlark::environment::Module; use starlark::eval::Evaluator; use starlark::syntax::AstModule; use starlark::syntax::Dialect; -use starlark::values::Heap; use starlark::values::list::UnpackList; use starlark::values::none::NoneType; use std::cell::RefCell; @@ -41,31 +40,28 @@ impl PolicyParser { let globals = GlobalsBuilder::extended_by(&[LibraryExtension::Typing]) .with(policy_builtins) .build(); - let module = Module::new(); - - let heap = Heap::new(); - - module.set("ARG_OPAQUE_VALUE", heap.alloc(ArgMatcher::OpaqueNonFile)); - module.set("ARG_RFILE", heap.alloc(ArgMatcher::ReadableFile)); - module.set("ARG_WFILE", heap.alloc(ArgMatcher::WriteableFile)); - module.set("ARG_RFILES", heap.alloc(ArgMatcher::ReadableFiles)); - module.set( - "ARG_RFILES_OR_CWD", - heap.alloc(ArgMatcher::ReadableFilesOrCwd), - ); - module.set("ARG_POS_INT", heap.alloc(ArgMatcher::PositiveInteger)); - module.set("ARG_SED_COMMAND", heap.alloc(ArgMatcher::SedCommand)); - module.set( - "ARG_UNVERIFIED_VARARGS", - heap.alloc(ArgMatcher::UnverifiedVarargs), - ); - let policy_builder = PolicyBuilder::new(); - { + Module::with_temp_heap(|module| { + let heap = module.heap(); + module.set("ARG_OPAQUE_VALUE", heap.alloc(ArgMatcher::OpaqueNonFile)); + module.set("ARG_RFILE", heap.alloc(ArgMatcher::ReadableFile)); + module.set("ARG_WFILE", heap.alloc(ArgMatcher::WriteableFile)); + module.set("ARG_RFILES", heap.alloc(ArgMatcher::ReadableFiles)); + module.set( + "ARG_RFILES_OR_CWD", + heap.alloc(ArgMatcher::ReadableFilesOrCwd), + ); + module.set("ARG_POS_INT", heap.alloc(ArgMatcher::PositiveInteger)); + module.set("ARG_SED_COMMAND", heap.alloc(ArgMatcher::SedCommand)); + module.set( + "ARG_UNVERIFIED_VARARGS", + heap.alloc(ArgMatcher::UnverifiedVarargs), + ); + let mut eval = Evaluator::new(&module); eval.extra = Some(&policy_builder); - eval.eval_module(ast, &globals)?; - } + eval.eval_module(ast, &globals).map(|_| ()) + })?; let policy = policy_builder.build(); policy.map_err(|e| starlark::Error::new_kind(starlark::ErrorKind::Other(e.into()))) } diff --git a/codex-rs/execpolicy/src/parser.rs b/codex-rs/execpolicy/src/parser.rs index 5d01df18bb..33262db1b3 100644 --- a/codex-rs/execpolicy/src/parser.rs +++ b/codex-rs/execpolicy/src/parser.rs @@ -65,12 +65,13 @@ impl PolicyParser { ) .map_err(Error::Starlark)?; let globals = GlobalsBuilder::standard().with(policy_builtins).build(); - let module = Module::new(); - { + Module::with_temp_heap(|module| { let mut eval = Evaluator::new(&module); eval.extra = Some(&self.builder); - eval.eval_module(ast, &globals).map_err(Error::Starlark)?; - } + eval.eval_module(ast, &globals) + .map(|_| ()) + .map_err(Error::Starlark) + })?; self.builder .borrow() .validate_pending_examples_from(pending_validation_count)?; From 5a440c03f2f3393169c5df517d1fd8eee969e45e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 7 Jun 2026 23:12:05 -0400 Subject: [PATCH 105/141] fix(tui): scope MCP startup status by thread (#26639) ## Why MCP startup failures from spawned subagents were rendered as global notifications, so a child thread's failure could pollute the visible parent transcript. Routing the notification to the child exposed two related replay problems: session refresh could discard the buffered event, and a newly created child `ChatWidget` did not know the expected MCP server set, which could leave its startup spinner running after every server had settled. MCP startup diagnostics should remain visible in the thread that owns the startup without affecting other transcripts. The protocol also needs to support a future app-scoped MCP lifecycle where startup is not owned by any thread. ## Reported Behavior The [originating Slack report](https://openai.slack.com/archives/C08JZTV654K/p1780604538859939) called out that using subagents could turn MCP startup failures into a wall of yellow CLI warnings because repeated failures were not deduplicated. The intended behavior is for those diagnostics to remain visible once in the thread that owns the startup, without polluting the parent transcript. ## What Changed - add nullable `threadId` ownership to `mcpServer/startupStatus/updated` - populate it from the app-server conversation ID for the current thread-scoped lifecycle and regenerate the protocol schema and TypeScript artifacts - treat a missing or null `threadId` as app-scoped without injecting it into the active chat transcript - route and buffer thread-owned MCP startup notifications by thread in the TUI - preserve buffered MCP startup events across child session refresh - seed expected MCP servers before replaying a thread snapshot so startup reaches its terminal state - suppress an identical repeated failure warning for the same server within one startup round The owning thread still renders the detailed failure and final `MCP startup incomplete (...)` summary. ## How to Test 1. Configure an optional MCP server named `smoke` that exits during initialization. 2. Launch the TUI with multi-agent support enabled. 3. Confirm the main thread's own startup failure renders one detailed `smoke` warning and one incomplete-startup summary. 4. Spawn exactly one subagent. 5. Confirm the parent transcript does not receive the subagent's MCP startup failure. 6. Switch to the subagent thread and confirm it contains exactly one detailed `smoke` failure and one incomplete-startup summary. 7. Confirm the subagent's MCP startup spinner disappears and the thread remains usable. 8. Switch between the parent and subagent and confirm the warnings neither move nor duplicate. Targeted tests: - `just test -p codex-app-server-protocol` - `just test -p codex-app-server thread_start_emits_mcp_server_status_updated_notifications` - `just test -p codex-tui mcp_startup` The parent/child behavior and spinner completion were also exercised manually in tmux. `just argument-comment-lint` was attempted but blocked by an unrelated local Bazel LLVM empty-glob failure; touched Rust callsites were inspected manually. --- .../schema/json/ServerNotification.json | 6 + .../codex_app_server_protocol.schemas.json | 6 + .../codex_app_server_protocol.v2.schemas.json | 6 + .../McpServerStatusUpdatedNotification.json | 6 + .../v2/McpServerStatusUpdatedNotification.ts | 2 +- .../src/protocol/v2/mcp.rs | 1 + .../src/protocol/v2/tests.rs | 27 +++ codex-rs/app-server/README.md | 4 +- .../app-server/src/bespoke_event_handling.rs | 1 + .../app-server/tests/suite/v2/thread_start.rs | 4 +- .../tui/src/app/app_server_event_targets.rs | 41 +++- codex-rs/tui/src/app/app_server_events.rs | 11 +- codex-rs/tui/src/app/tests.rs | 183 +++++++++++++++++- codex-rs/tui/src/app/thread_events.rs | 28 +++ codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/chatwidget/mcp_startup.rs | 8 +- codex-rs/tui/src/chatwidget/protocol.rs | 6 - .../tui/src/chatwidget/tests/mcp_startup.rs | 43 ++++ 18 files changed, 363 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 96b24f9324..dac3a4fdff 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2234,6 +2234,12 @@ }, "status": { "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0c29f4a097..1873ed317e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11462,6 +11462,12 @@ }, "status": { "$ref": "#/definitions/v2/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b378a54d16..38c74b1859 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7964,6 +7964,12 @@ }, "status": { "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json index b0e2cd5a07..87dab90cff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json @@ -23,6 +23,12 @@ }, "status": { "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts index 42f5881c5d..b5c0a9058c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { McpServerStartupState } from "./McpServerStartupState"; -export type McpServerStatusUpdatedNotification = { name: string, status: McpServerStartupState, error: string | null, }; +export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index ae61f12b2d..37197a2237 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -235,6 +235,7 @@ pub enum McpServerStartupState { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct McpServerStatusUpdatedNotification { + pub thread_id: Option, pub name: String, pub status: McpServerStartupState, pub error: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 4a683be990..d064795f2b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2040,6 +2040,33 @@ fn mcp_server_status_serializes_absent_server_info_as_null() { ); } +#[test] +fn mcp_server_status_updated_accepts_missing_thread_id() { + let notification: McpServerStatusUpdatedNotification = serde_json::from_value(json!({ + "name": "optional_broken", + "status": "failed", + "error": "handshake failed", + })) + .expect("notification without threadId should deserialize"); + + let expected = McpServerStatusUpdatedNotification { + thread_id: None, + name: "optional_broken".to_string(), + status: McpServerStartupState::Failed, + error: Some("handshake failed".to_string()), + }; + assert_eq!(notification, expected); + assert_eq!( + serde_json::to_value(notification).expect("notification should serialize"), + json!({ + "threadId": null, + "name": "optional_broken", + "status": "failed", + "error": "handshake failed", + }) + ); +} + #[test] fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { let response = ListMcpServerStatusResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d918f70b73..a06adf03e7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1233,7 +1233,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o ### MCP server startup events -- `mcpServer/startupStatus/updated` — `{ name, status, error }` when app-server observes an MCP server startup transition. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`. +- `mcpServer/startupStatus/updated` — `{ threadId, name, status, error }` when app-server observes an MCP server startup transition. `threadId` identifies the owning thread when startup is thread-scoped and is `null` when startup is app-scoped. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`. ### Turn events @@ -1785,7 +1785,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. -- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`. +- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`. ### 1) Check auth state diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0cec31b6f2..547d0666ed 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -212,6 +212,7 @@ pub(crate) async fn apply_bespoke_event_handling( } }; let notification = McpServerStatusUpdatedNotification { + thread_id: Some(conversation_id.to_string()), name: update.server, status, error, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 5954342b9c..6366c6fbca 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -708,7 +708,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< .send_thread_start_request(ThreadStartParams::default()) .await?; - let _: ThreadStartResponse = to_response( + let start_response: ThreadStartResponse = to_response( timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(req_id)), @@ -745,6 +745,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< assert_eq!( starting, McpServerStatusUpdatedNotification { + thread_id: Some(start_response.thread.id.clone()), name: "optional_broken".to_string(), status: McpServerStartupState::Starting, error: None, @@ -777,6 +778,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< let ServerNotification::McpServerStatusUpdated(failed) = failed else { anyhow::bail!("unexpected notification variant"); }; + assert_eq!(failed.thread_id, Some(start_response.thread.id)); assert_eq!(failed.name, "optional_broken"); assert_eq!(failed.status, McpServerStartupState::Failed); assert!( diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index ea9fce90fb..800560d520 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -35,6 +35,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option notification.thread_id.as_deref(), ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()), + ServerNotification::McpServerStatusUpdated(notification) => { + match notification.thread_id.as_deref() { + Some(thread_id) => Some(thread_id), + None => return ServerNotificationThreadTarget::AppScoped, + } + } ServerNotification::SkillsChanged(_) - | ServerNotification::McpServerStatusUpdated(_) | ServerNotification::McpServerOauthLoginCompleted(_) | ServerNotification::AccountUpdated(_) | ServerNotification::AccountRateLimitsUpdated(_) @@ -184,6 +190,8 @@ mod tests { use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use codex_app_server_protocol::GuardianWarningNotification; + use codex_app_server_protocol::McpServerStartupState; + use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; @@ -259,6 +267,37 @@ mod tests { assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); } + #[test] + fn mcp_startup_notifications_route_to_threads() { + let thread_id = ThreadId::new(); + let notification = + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(thread_id.to_string()), + name: "sentry".to_string(), + status: McpServerStartupState::Failed, + error: Some("sentry is not logged in".to_string()), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } + + #[test] + fn mcp_startup_notifications_without_threads_are_app_scoped() { + let notification = + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "sentry".to_string(), + status: McpServerStartupState::Failed, + error: Some("sentry is not logged in".to_string()), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::AppScoped); + } + #[test] fn thread_settings_updated_notifications_route_to_threads() { let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index dea6308760..268dbd305b 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -15,10 +15,9 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; impl App { - fn refresh_mcp_startup_expected_servers_from_config(&mut self) { + pub(super) fn refresh_mcp_startup_expected_servers_from_config(&mut self) { let enabled_config_mcp_servers: Vec = self - .chat_widget - .config_ref() + .config .mcp_servers .get() .iter() @@ -141,6 +140,12 @@ impl App { ); return; } + ServerNotificationThreadTarget::AppScoped => { + tracing::debug!( + "ignoring app-scoped MCP startup notification without a TUI app-level target" + ); + return; + } ServerNotificationThreadTarget::Global => {} } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2e3e1b8c90..ac70930a3b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3353,24 +3353,33 @@ async fn side_thread_snapshot_skips_session_header_preamble() { } #[tokio::test] -async fn side_thread_ignores_global_mcp_startup_notifications() { +async fn primary_thread_ignores_child_mcp_startup_notifications() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; while app_event_rx.try_recv().is_ok() {} + let sentry_config = toml::from_str::("command = 'true'") + .expect("test MCP config should parse") + .try_into() + .expect("test MCP config should deserialize"); + app.config + .mcp_servers + .set(std::collections::HashMap::from([( + "sentry".to_string(), + sentry_config, + )])) + .expect("test MCP servers should accept any configuration"); let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); app.primary_thread_id = Some(parent_thread_id); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - app.sync_side_thread_ui(); + app.active_thread_id = Some(parent_thread_id); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(child_thread_id.to_string()), name: "sentry".to_string(), status: McpServerStartupState::Failed, error: Some("sentry is not logged in".to_string()), @@ -3380,6 +3389,168 @@ async fn side_thread_ignores_global_mcp_startup_notifications() { .await; assert!(app_event_rx.try_recv().is_err()); + let mut child_snapshot = app + .thread_event_channels + .get(&child_thread_id) + .expect("child thread channel should be created") + .store + .lock() + .await + .snapshot(); + assert!( + matches!( + child_snapshot.events.as_slice(), + [ThreadBufferedEvent::Notification( + ServerNotification::McpServerStatusUpdated(_) + )] + ), + "child MCP startup notification should be buffered for the child thread" + ); + + app.apply_refreshed_snapshot_thread( + child_thread_id, + AppServerStartedThread { + session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")), + turns: Vec::new(), + }, + &mut child_snapshot, + ) + .await; + app.replay_thread_snapshot(child_snapshot, /*resume_restored_queue*/ false); + + let mut rendered_cells = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120))); + } + } + let rendered = rendered_cells.join("\n"); + assert_eq!(app.chat_widget.thread_id(), Some(child_thread_id)); + assert_eq!(rendered.matches("sentry is not logged in").count(), 1); + assert_eq!( + rendered + .matches("MCP startup incomplete (failed: sentry)") + .count(), + 1 + ); +} + +#[tokio::test] +async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + while app_event_rx.try_recv().is_ok() {} + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "sentry".to_string(), + status: McpServerStartupState::Failed, + error: Some("sentry is not logged in".to_string()), + }), + ), + ) + .await; + + assert!(app_event_rx.try_recv().is_err()); + assert_eq!( + app.chat_widget.active_cell_transcript_lines(/*width*/ 120), + None + ); +} + +#[tokio::test] +async fn active_side_thread_renders_live_mcp_startup_notifications() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + while app_event_rx.try_recv().is_ok() {} + let sentry_config = toml::from_str::("command = 'true'") + .expect("test MCP config should parse") + .try_into() + .expect("test MCP config should deserialize"); + app.config + .mcp_servers + .set(std::collections::HashMap::from([( + "sentry".to_string(), + sentry_config, + )])) + .expect("test MCP servers should accept any configuration"); + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.ensure_thread_channel(side_thread_id); + app.activate_thread_channel(side_thread_id).await; + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(test_thread_session( + side_thread_id, + test_path_buf("/tmp/side"), + )), + turns: Vec::new(), + events: Vec::new(), + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + app.sync_side_thread_ui(); + + for status in [ + McpServerStartupState::Starting, + McpServerStartupState::Failed, + ] { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(side_thread_id.to_string()), + name: "sentry".to_string(), + status, + error: matches!(status, McpServerStartupState::Failed) + .then(|| "sentry is not logged in".to_string()), + }), + ), + ) + .await; + } + + let mut active_thread_events = Vec::new(); + let active_thread_rx = app + .active_thread_rx + .as_mut() + .expect("side thread receiver should be active"); + while let Ok(event) = active_thread_rx.try_recv() { + active_thread_events.push(event); + } + for event in active_thread_events { + app.handle_thread_event_now(event); + } + + let mut rendered_cells = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120))); + } + } + let rendered = rendered_cells.join("\n"); + assert!(app.chat_widget.side_conversation_active()); + assert_eq!(rendered.matches("sentry is not logged in").count(), 1); + assert_eq!( + rendered + .matches("MCP startup incomplete (failed: sentry)") + .count(), + 1 + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index a4e514a433..5f7a58da1f 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -50,6 +50,7 @@ impl ThreadEventStore { ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_)) | ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_)) + | ThreadBufferedEvent::Notification(ServerNotification::McpServerStatusUpdated(_)) | ThreadBufferedEvent::FeedbackSubmission(_) ) } @@ -590,4 +591,31 @@ mod tests { ] ); } + + #[test] + fn thread_event_store_rebase_preserves_mcp_startup_notifications() { + let thread_id = ThreadId::new(); + let notification = ServerNotification::McpServerStatusUpdated( + codex_app_server_protocol::McpServerStatusUpdatedNotification { + thread_id: Some(thread_id.to_string()), + name: "sentry".to_string(), + status: codex_app_server_protocol::McpServerStartupState::Failed, + error: Some("sentry is not logged in".to_string()), + }, + ); + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.push_notification(notification.clone()); + + store.rebase_buffer_after_session_refresh(); + + let snapshot = store.snapshot(); + let actual = match snapshot.events.as_slice() { + [ThreadBufferedEvent::Notification(actual)] => actual, + other => panic!("expected one buffered MCP notification, saw: {other:?}"), + }; + assert_eq!( + serde_json::to_value(actual).expect("MCP notification should serialize"), + serde_json::to_value(notification).expect("MCP notification should serialize"), + ); + } } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 7e9f91befd..099c81fb70 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1261,6 +1261,7 @@ impl App { snapshot: ThreadEventSnapshot, resume_restored_queue: bool, ) { + self.refresh_mcp_startup_expected_servers_from_config(); let should_buffer_replay = self.terminal_resize_reflow_enabled() && (!snapshot.turns.is_empty() || !snapshot.events.is_empty()); if should_buffer_replay { diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index 05d3def329..32839b437f 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -83,7 +83,13 @@ impl ChatWidget { // per-server failures immediately. let mut startup_status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &status { - self.on_warning(error); + let already_reported = matches!( + startup_status.get(&server), + Some(McpStartupStatus::Failed { error: previous }) if previous == error + ); + if !already_reported { + self.on_warning(error); + } } startup_status.insert(server, status); startup_status diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 1ddbe40d54..9b77da0372 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -6,12 +6,6 @@ impl ChatWidget { notification: ServerNotification, replay_kind: Option, ) { - if self.active_side_conversation - && replay_kind.is_none() - && matches!(notification, ServerNotification::McpServerStatusUpdated(_)) - { - return; - } let from_replay = replay_kind.is_some(); let is_resume_initial_replay = matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index ba862a5293..8b0d5ca472 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -4,6 +4,7 @@ use pretty_assertions::assert_eq; fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) { chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some("thread-1".to_string()), name: name.to_string(), status, error: None, @@ -15,6 +16,7 @@ fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartup fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) { chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some("thread-1".to_string()), name: name.to_string(), status: McpServerStartupState::Failed, error: Some(error.to_string()), @@ -23,6 +25,42 @@ fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) { ); } +#[tokio::test] +async fn mcp_startup_dedupes_same_round_duplicate_failure_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", + ); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", + ); + + let failure_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_eq!( + failure_text, + "⚠ MCP client for `alpha` failed to start: handshake failed\n" + ); + + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_eq!(summary_text, "⚠ MCP startup incomplete (failed: alpha)\n"); +} + #[tokio::test] async fn mcp_startup_header_booting_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -110,6 +148,11 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", + ); notify_mcp_status_error( &mut chat, "alpha", From ed6e5cf919fdb8b388eb5643669dc175f26188cb Mon Sep 17 00:00:00 2001 From: rka-oai Date: Sun, 7 Jun 2026 23:18:23 -0700 Subject: [PATCH 106/141] [codex] Enable standalone web search in code mode (#26719) ## What - Consume plaintext `output` from standalone search while retaining optional `encrypted_output` parsing. - Expose `web.run` to code mode and return search output to nested JavaScript calls. - Cover direct and code-mode standalone search paths with integration tests. ## Why `/v1/alpha/search` now returns plaintext output, which code mode needs to consume standalone search results. ## Test plan - `just test -p codex-api` - `just test -p codex-web-search-extension` - `just test -p codex-core code_mode_can_call_standalone_web_search` - `just test -p codex-app-server standalone_web_search_round_trips_output` --- .../app-server/tests/suite/v2/web_search.rs | 7 +- codex-rs/codex-api/src/endpoint/search.rs | 12 +- codex-rs/codex-api/src/search.rs | 3 +- codex-rs/core/tests/suite/code_mode.rs | 110 ++++++++++++++++++ codex-rs/ext/web-search/src/output.rs | 28 ++--- codex-rs/ext/web-search/src/tool.rs | 8 +- 6 files changed, 141 insertions(+), 27 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 13c595d148..626767132b 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -41,7 +41,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] -async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { +async fn standalone_web_search_round_trips_output() -> Result<()> { let call_id = "web-run-1"; let server = responses::start_mock_server().await; mount_search_response(&server).await; @@ -170,8 +170,8 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { "type": "function_call_output", "call_id": call_id, "output": [{ - "type": "encrypted_content", - "encrypted_content": "ciphertext", + "type": "input_text", + "text": "Search result", }], }) ); @@ -259,6 +259,7 @@ async fn mount_search_response(server: &MockServer) { .and(path("/api/codex/alpha/search")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "encrypted_output": "ciphertext", + "output": "Search result", }))) .expect(1) .mount(server) diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index d01fbfb780..143048c954 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -134,10 +134,13 @@ mod tests { } #[tokio::test] - async fn search_posts_typed_request_and_parses_encrypted_output() { + async fn search_posts_typed_request_and_parses_output() { let transport = CapturingTransport::new( - serde_json::to_vec(&json!({"encrypted_output": "ciphertext"})) - .expect("serialize response"), + serde_json::to_vec(&json!({ + "encrypted_output": "ciphertext", + "output": "search result", + })) + .expect("serialize response"), ); let client = SearchClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); @@ -203,7 +206,8 @@ mod tests { assert_eq!( response, SearchResponse { - encrypted_output: "ciphertext".to_string(), + encrypted_output: Some("ciphertext".to_string()), + output: "search result".to_string(), } ); diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index 061b3ac8c6..bae7c8a7da 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -280,5 +280,6 @@ pub enum AllowedCaller { #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct SearchResponse { - pub encrypted_output: String, + pub encrypted_output: Option, + pub output: String, } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index fca4556972..f5f17a09bd 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -6,9 +6,11 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; +use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -17,6 +19,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use codex_web_search_extension::install as install_web_search_extension; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::AppsTestToolLoading; use core_test_support::apps_test_server::DIRECT_CALENDAR_APP_ONLY_TOOL; @@ -45,9 +48,14 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::path::Path; +use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; fn custom_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec { match req.custom_tool_call_output(call_id).get("output") { @@ -191,6 +199,108 @@ async fn run_code_mode_turn_with_config( Ok((test, second_mock)) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_call_standalone_web_search() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + Mock::given(method("POST")) + .and(path("/v1/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "output": "Search result", + }))) + .expect(1) + .mount(&server) + .await; + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call( + "call-1", + "exec", + r#" +const result = await tools.web__run({ + search_query: [{ q: "standalone web search" }], +}); +text(result); +"#, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let follow_up_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + let auth = CodexAuth::from_api_key("dummy"); + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); + let mut extension_builder = ExtensionRegistryBuilder::::new(); + install_web_search_extension(&mut extension_builder, auth_manager); + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(Arc::new(extension_builder.build())) + .with_model("test-gpt-5.1-codex") + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("code mode should be enabled"); + config + .features + .enable(Feature::StandaloneWebSearch) + .expect("standalone web search should be enabled"); + config + .web_search_mode + .set(WebSearchMode::Live) + .expect("web search mode should be accepted"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("Search the web from code mode").await?; + + let search_request = server + .received_requests() + .await + .expect("received requests should be available") + .into_iter() + .find(|request| request.url.path() == "/v1/alpha/search") + .expect("standalone search request should be sent"); + let search_body = search_request + .body_json::() + .expect("search request body should be JSON"); + assert_eq!( + search_body["model"], + serde_json::json!("test-gpt-5.1-codex") + ); + assert_eq!( + search_body["commands"], + serde_json::json!({ + "search_query": [{"q": "standalone web search"}], + }) + ); + assert_eq!( + search_body["settings"], + serde_json::json!({ + "allowed_callers": ["direct"], + "external_web_access": true, + }) + ); + assert_eq!( + custom_tool_output_last_non_empty_text(&follow_up_mock.single_request(), "call-1"), + Some("Search result".to_string()) + ); + + Ok(()) +} + async fn run_code_mode_turn_with_rmcp( server: &MockServer, prompt: &str, diff --git a/codex-rs/ext/web-search/src/output.rs b/codex-rs/ext/web-search/src/output.rs index 124271c216..799897b62d 100644 --- a/codex-rs/ext/web-search/src/output.rs +++ b/codex-rs/ext/web-search/src/output.rs @@ -4,19 +4,19 @@ use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; -pub(crate) struct EncryptedSearchOutput { - encrypted_output: String, +pub(crate) struct SearchOutput { + output: String, } -impl EncryptedSearchOutput { - pub(crate) fn new(encrypted_output: String) -> Self { - Self { encrypted_output } +impl SearchOutput { + pub(crate) fn new(output: String) -> Self { + Self { output } } } -impl ToolOutput for EncryptedSearchOutput { +impl ToolOutput for SearchOutput { fn log_preview(&self) -> String { - "[encrypted standalone web search output]".to_string() + "[standalone web search output]".to_string() } fn success_for_logging(&self) -> bool { @@ -29,8 +29,8 @@ impl ToolOutput for EncryptedSearchOutput { ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::EncryptedContent { - encrypted_content: self.encrypted_output.clone(), + FunctionCallOutputContentItem::InputText { + text: self.output.clone(), }, ]), } @@ -45,12 +45,12 @@ mod tests { use codex_protocol::models::ResponseInputItem; use pretty_assertions::assert_eq; - use super::EncryptedSearchOutput; + use super::SearchOutput; use super::ToolOutput; #[test] - fn emits_encrypted_function_call_output() { - let output = EncryptedSearchOutput::new("encrypted-search-output".to_string()); + fn emits_plaintext_function_call_output() { + let output = SearchOutput::new("search output".to_string()); assert_eq!( output.to_response_item( @@ -62,8 +62,8 @@ mod tests { ResponseInputItem::FunctionCallOutput { call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::EncryptedContent { - encrypted_content: "encrypted-search-output".to_string(), + FunctionCallOutputContentItem::InputText { + text: "search output".to_string(), }, ]), } diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 9a09b73ebb..35f126dd6b 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -26,7 +26,7 @@ use http::HeaderMap; use url::Url; use crate::history::recent_input; -use crate::output::EncryptedSearchOutput; +use crate::output::SearchOutput; use crate::schema::commands_schema; pub(crate) const WEB_NAMESPACE: &str = "web"; @@ -67,7 +67,7 @@ impl ToolExecutor for WebSearchTool { } fn exposure(&self) -> ToolExposure { - ToolExposure::DirectModelOnly + ToolExposure::Direct } fn supports_parallel_tool_calls(&self) -> bool { @@ -114,9 +114,7 @@ impl ToolExecutor for WebSearchTool { .emit_completed(web_search_item(&call.call_id, command_action)) .await; - Ok(Box::new(EncryptedSearchOutput::new( - response.encrypted_output, - ))) + Ok(Box::new(SearchOutput::new(response.output))) } } From 4e803a017c958dd37eb251372ba810232d3e84ba Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 8 Jun 2026 10:24:48 +0200 Subject: [PATCH 107/141] feat: add v2 agent residency lru (#26632) ## Why Multi-agent v2 treats agents as durable logical agents, not just live entries in `ThreadManager`. After the reload-on-delivery change, a v2 agent can be addressed even if its thread is not currently loaded. This PR adds the next layer: loaded v2 subagents can be paged out of `ThreadManager` when the session has too many resident agents. That keeps residency separate from logical identity and prepares the stack for making v2 concurrency count active execution instead of existing agents. ## What Changed - Add an `AgentControl`-scoped LRU for resident v2 subagents. - Reserve residency before spawning or reloading a v2 subagent. - If resident capacity is full, unload the least-recently-used idle v2 subagent from `ThreadManager`. - Keep `ThreadManager` as a primitive loaded-thread store; it does not own the LRU policy. - Keep unloaded agents registered and durable so they can be reloaded by the delivery path. - Preserve the existing v2 cap semantics by using the derived non-root v2 cap for residency. Eviction is intentionally conservative. A thread is unloadable only when it is a v2 subagent, has completed or errored, has no active turn, and has no pending mailbox work. Before removal, the rollout is materialized and flushed. ## Assumptions And Non-Goals - PR #26623 provides the reload-on-delivery path for unloaded v2 agents. - `ThreadManager` membership means loaded/resident, not logical agent existence. - `AgentRegistry` remains the logical identity/metadata source for v2 agents that may be unloaded. - `list_agents` remains a recent/resident view for now. - This does not change active execution concurrency; that is the next PR. - This does not change `close_agent` semantics. - This does not change or remove `resume_agent`. - This does not add a new residency config knob. ## Stack 1. V2 durable lookup and reload on delivery (#26623) - reload unloaded v2 agents before delivering follow-up/input. 2. V2 residency LRU (this PR) - unload idle resident v2 agents from `ThreadManager` when resident capacity is full. 3. V2 active-execution concurrency - count running non-root v2 turns instead of logical agents. 4. V2 close/interrupt semantics - make v2 close interrupt the current turn without deleting durable identity. 5. V2 resume cleanup - remove the manual resume surface for v2 while keeping internal reload support. ## Validation - Added focused coverage for the residency LRU eviction path. - Local clippy/check/tests were not run; CI will cover them. --- codex-rs/core/src/agent/control.rs | 5 + codex-rs/core/src/agent/control/legacy.rs | 1 + codex-rs/core/src/agent/control/residency.rs | 240 ++++++++++++++++++ .../core/src/agent/control/residency_tests.rs | 107 ++++++++ codex-rs/core/src/agent/control/spawn.rs | 30 ++- 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/src/agent/control/residency.rs create mode 100644 codex-rs/core/src/agent/control/residency_tests.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 6c6650a559..e9994883f5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -42,9 +42,12 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; +use self::residency::V2Residency; + const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; mod legacy; +mod residency; mod spawn; #[derive(Clone, Debug, PartialEq, Eq)] @@ -91,6 +94,7 @@ pub(crate) struct AgentControl { /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. manager: Weak, state: Arc, + v2_residency: Arc, } impl AgentControl { @@ -183,6 +187,7 @@ impl AgentControl { ) -> CodexResult { if matches!(result, Err(CodexErr::InternalAgentDied)) { let _ = state.remove_thread(&agent_id).await; + self.forget_v2_residency(agent_id); self.state.release_spawned_thread(agent_id); } result diff --git a/codex-rs/core/src/agent/control/legacy.rs b/codex-rs/core/src/agent/control/legacy.rs index e85090b905..aa41472687 100644 --- a/codex-rs/core/src/agent/control/legacy.rs +++ b/codex-rs/core/src/agent/control/legacy.rs @@ -19,6 +19,7 @@ impl AgentControl { state.send_op(agent_id, Op::Shutdown {}).await }; let _ = state.remove_thread(&agent_id).await; + self.forget_v2_residency(agent_id); self.state.release_spawned_thread(agent_id); result } diff --git a/codex-rs/core/src/agent/control/residency.rs b/codex-rs/core/src/agent/control/residency.rs new file mode 100644 index 0000000000..8056fedeba --- /dev/null +++ b/codex-rs/core/src/agent/control/residency.rs @@ -0,0 +1,240 @@ +use super::AgentControl; +use crate::agent::AgentStatus; +use crate::codex_thread::CodexThread; +use crate::config::Config; +use crate::thread_manager::ThreadManagerState; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::SessionSource; +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::Mutex; +use tracing::warn; + +#[derive(Default)] +pub(super) struct V2Residency { + state: Mutex, +} + +#[derive(Default)] +struct V2ResidencyState { + residents: VecDeque, + pending_slots: usize, +} + +pub(super) struct V2ResidencySlot { + residency: Arc, + active: bool, +} + +impl V2ResidencySlot { + pub(super) fn commit(mut self, thread_id: ThreadId) { + self.residency.commit_slot(thread_id); + self.active = false; + } +} + +impl Drop for V2ResidencySlot { + fn drop(&mut self) { + if self.active { + self.residency.release_pending_slot(); + } + } +} + +impl AgentControl { + pub(super) async fn reserve_v2_residency_slot( + &self, + state: &Arc, + config: &Config, + protected_thread_id: Option, + ) -> CodexResult { + let capacity = config + .effective_agent_max_threads(MultiAgentVersion::V2) + .unwrap_or(usize::MAX); + Arc::clone(&self.v2_residency) + .reserve_slot(state, capacity, protected_thread_id) + .await + } + + pub(super) async fn touch_loaded_v2_residency( + &self, + state: &Arc, + thread_id: ThreadId, + ) { + if let Ok(thread) = state.get_thread(thread_id).await + && is_resident_candidate(thread.as_ref()) + { + self.v2_residency.touch(thread_id); + } + } + + pub(super) fn forget_v2_residency(&self, thread_id: ThreadId) { + self.v2_residency.remove(thread_id); + } +} + +impl V2Residency { + async fn reserve_slot( + self: Arc, + manager: &Arc, + capacity: usize, + protected_thread_id: Option, + ) -> CodexResult { + loop { + if self.try_reserve_pending_slot(capacity) { + return Ok(V2ResidencySlot { + residency: self, + active: true, + }); + } + if !self + .try_unload_one_resident(manager, protected_thread_id) + .await + { + return Err(CodexErr::AgentLimitReached { + max_threads: capacity, + }); + } + } + } + + fn try_reserve_pending_slot(&self, capacity: usize) -> bool { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.residents.len().saturating_add(state.pending_slots) >= capacity { + return false; + } + state.pending_slots += 1; + true + } + + async fn try_unload_one_resident( + &self, + manager: &Arc, + protected_thread_id: Option, + ) -> bool { + let candidates_to_scan = self.resident_count(); + for _ in 0..candidates_to_scan { + let Some(candidate_thread_id) = self.pop_lru_candidate(protected_thread_id) else { + return false; + }; + let Some(candidate_thread) = manager + .get_thread(candidate_thread_id) + .await + .ok() + .filter(|thread| is_resident_candidate(thread)) + else { + continue; + }; + if !is_unloadable(candidate_thread.as_ref()).await { + self.touch(candidate_thread_id); + continue; + } + candidate_thread.ensure_rollout_materialized().await; + if let Err(err) = candidate_thread.flush_rollout().await { + warn!( + "failed to flush v2 resident thread before unloading {candidate_thread_id}: {err}" + ); + self.touch(candidate_thread_id); + continue; + } + let _ = manager.remove_thread(&candidate_thread_id).await; + return true; + } + false + } + + fn resident_count(&self) -> usize { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .residents + .len() + } + + fn pop_lru_candidate(&self, protected_thread_id: Option) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let candidates_to_scan = state.residents.len(); + for _ in 0..candidates_to_scan { + let candidate_thread_id = state.residents.pop_front()?; + if Some(candidate_thread_id) == protected_thread_id { + state.residents.push_back(candidate_thread_id); + continue; + } + return Some(candidate_thread_id); + } + None + } + + fn touch(&self, thread_id: ThreadId) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + touch_resident(&mut state.residents, thread_id); + } + + fn remove(&self, thread_id: ThreadId) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .residents + .retain(|resident_thread_id| *resident_thread_id != thread_id); + } + + fn commit_slot(&self, thread_id: ThreadId) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.pending_slots = state.pending_slots.saturating_sub(1); + touch_resident(&mut state.residents, thread_id); + } + + fn release_pending_slot(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.pending_slots = state.pending_slots.saturating_sub(1); + } +} + +fn touch_resident(residents: &mut VecDeque, thread_id: ThreadId) { + residents.retain(|resident_thread_id| *resident_thread_id != thread_id); + residents.push_back(thread_id); +} + +fn is_resident_candidate(thread: &CodexThread) -> bool { + thread.multi_agent_version() == Some(MultiAgentVersion::V2) + && is_v2_resident_session_source(&thread.session_source) +} + +pub(super) fn is_v2_resident_session_source(session_source: &SessionSource) -> bool { + matches!(session_source, SessionSource::SubAgent(_)) +} + +async fn is_unloadable(thread: &CodexThread) -> bool { + matches!( + thread.agent_status().await, + AgentStatus::Completed(_) | AgentStatus::Errored(_) + ) && thread.codex.session.active_turn.lock().await.is_none() + && !thread + .codex + .session + .input_queue + .has_pending_mailbox_items() + .await +} + +#[cfg(test)] +#[path = "residency_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs new file mode 100644 index 0000000000..a0338eda06 --- /dev/null +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -0,0 +1,107 @@ +use crate::ThreadManager; +use crate::agent::AgentControl; +use crate::codex_thread::CodexThread; +use crate::config::Config; +use crate::config::test_config; +use crate::thread_manager::ThreadManagerState; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TurnCompleteEvent; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +#[tokio::test] +async fn residency_slot_reservation_unloads_oldest_idle_v2_agent() { + let mut config = test_config().await; + let _ = config.features.enable(Feature::MultiAgentV2); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; + let temp_home = tempfile::tempdir().expect("create temp home"); + config.codex_home = temp_home.path().to_path_buf().try_into().unwrap(); + config.cwd = temp_home.path().to_path_buf().try_into().unwrap(); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let root = manager + .start_thread(config.clone()) + .await + .expect("start root thread"); + let control = manager.agent_control(); + let state = control.upgrade().expect("thread manager should be live"); + + let first_slot = control + .reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None) + .await + .expect("first resident slot"); + let first = + spawn_v2_subagent(&control, &state, config.clone(), root.thread_id, "worker-1").await; + first_slot.commit(first.thread_id); + mark_thread_completed(first.thread.as_ref()).await; + + let second_slot = control + .reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None) + .await + .expect("second resident slot should evict the first idle agent"); + match manager.get_thread(first.thread_id).await { + Err(CodexErr::ThreadNotFound(thread_id)) => assert_eq!(thread_id, first.thread_id), + Err(err) => panic!("expected evicted thread to be missing, got {err:?}"), + Ok(_) => panic!("expected evicted thread to be missing"), + } + let second = spawn_v2_subagent(&control, &state, config, root.thread_id, "worker-2").await; + second_slot.commit(second.thread_id); + + assert!(manager.get_thread(root.thread_id).await.is_ok()); + assert!(manager.get_thread(second.thread_id).await.is_ok()); +} + +async fn spawn_v2_subagent( + control: &AgentControl, + state: &Arc, + config: Config, + parent_thread_id: ThreadId, + label: &str, +) -> crate::thread_manager::NewThread { + state + .spawn_new_thread_with_source( + config, + control.clone(), + SessionSource::SubAgent(SubAgentSource::Other(label.to_string())), + Some(parent_thread_id), + /*forked_from_thread_id*/ None, + Some(ThreadSource::Subagent), + /*metrics_service_name*/ None, + /*inherited_shell_snapshot*/ None, + /*inherited_exec_policy*/ None, + /*environments*/ None, + ) + .await + .expect("spawn v2 subagent") +} + +async fn mark_thread_completed(thread: &CodexThread) { + let turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .send_event( + turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ) + .await; + // The fixture has no task runner to clear the turn after the terminal event. + *thread.codex.session.active_turn.lock().await = None; +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 8df3b0a07f..e3536e89f9 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,3 +1,4 @@ +use super::residency::is_v2_resident_session_source; use super::*; const AGENT_NAMES: &str = include_str!("../agent_names.txt"); @@ -116,6 +117,7 @@ impl AgentControl { ) -> CodexResult<()> { let state = self.upgrade()?; if state.get_thread(thread_id).await.is_ok() { + self.touch_loaded_v2_residency(&state, thread_id).await; return Ok(()); } if self.state.agent_metadata_for_thread(thread_id).is_none() { @@ -143,6 +145,9 @@ impl AgentControl { if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) { return Err(CodexErr::ThreadNotFound(thread_id)); } + let residency_slot = self + .reserve_v2_residency_slot(&state, &config, Some(thread_id)) + .await?; let (session_source, _) = initial_history .get_resumed_session_sources() @@ -170,11 +175,14 @@ impl AgentControl { .await { Ok(reloaded_thread) => { + residency_slot.commit(reloaded_thread.thread_id); state.notify_thread_created(reloaded_thread.thread_id); Ok(()) } Err(err) => { if state.get_thread(thread_id).await.is_ok() { + drop(residency_slot); + self.touch_loaded_v2_residency(&state, thread_id).await; return Ok(()); } Err(err) @@ -200,7 +208,24 @@ impl AgentControl { ) .await; let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let spawn_uses_v2_residency = multi_agent_version == MultiAgentVersion::V2 + && session_source + .as_ref() + .is_some_and(is_v2_resident_session_source); + let residency_slot = if spawn_uses_v2_residency { + Some( + self.reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None) + .await?, + ) + } else { + None + }; + let reservation_max_threads = if spawn_uses_v2_residency { + None + } else { + agent_max_threads + }; + let mut reservation = self.state.reserve_spawn_slot(reservation_max_threads)?; let inheritance = SpawnAgentThreadInheritance { shell_snapshot: self .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) @@ -264,6 +289,9 @@ impl AgentControl { }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); + if let Some(residency_slot) = residency_slot { + residency_slot.commit(new_thread.thread_id); + } if let Some(SessionSource::SubAgent( subagent_source @ SubAgentSource::ThreadSpawn { From 8f1aad58dc3a684afecdd9efd31d074762da3c24 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 8 Jun 2026 11:39:08 +0200 Subject: [PATCH 108/141] Ignore proc-macro-error2 advisory (#26974) ## Summary - ignore RUSTSEC-2026-0173 in cargo-deny and cargo-audit config - document that proc-macro-error2 is pulled in transitively via i18n-embed-fl/age/codex-secrets - leave the ignore temporary until codex-secrets moves off age or age drops i18n-embed-fl ## Validation - just fmt - cargo deny check --hide-inclusion-graph --- codex-rs/.cargo/audit.toml | 1 + codex-rs/deny.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index 6d963b725d..c1d8ff87ee 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -9,4 +9,5 @@ ignore = [ "RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it "RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net "RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net + "RUSTSEC-2026-0173", # proc-macro-error2 via i18n-embed-fl/age/codex-secrets; remove when local secrets storage migrates off age or age drops i18n-embed-fl ] diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index a5c06e862e..983fd5b962 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -81,6 +81,7 @@ ignore = [ { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2026-0118", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; DNSSEC features are not enabled; remove when rama updates to hickory 0.26.1 or hickory-net" }, { id = "RUSTSEC-2026-0119", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; no fixed rama release is available yet; remove when rama updates to hickory 0.26.1 or hickory-net" }, + { id = "RUSTSEC-2026-0173", reason = "proc-macro-error2 is unmaintained; pulled in via i18n-embed-fl/age used by codex-secrets local storage; remove when codex-secrets migrates off age or age drops i18n-embed-fl" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. From 743f5aad38accd52da34bf4dcbdd1215a8c3ab9a Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 8 Jun 2026 14:21:28 +0200 Subject: [PATCH 109/141] feat: count V2 concurrency by active execution (#26969) ## Why Multi-Agent V2 concurrency should count active non-root turns, not resident or durable agent threads. The limit is intentionally best effort: admission checks are synchronous, but concurrent successful checks may overshoot slightly. ## What changed - Keep one root-derived execution limit on the shared `AgentControl`. - Count active V2 subagent turns with an RAII guard owned by `RunningTask`. - Check capacity before spawning or starting an idle agent, including direct app-server `turn/start` submissions. - Preserve queued delivery for agents that are already running. - Exempt automatic idle continuations so `/goal` work is not dropped when capacity is temporarily full. - Keep root and V1 turns outside this limiter. ## Test coverage - `execution_guards_count_active_v2_subagent_turns` - `execution_guards_ignore_root_and_v1_turns` - `v2_nested_spawn_checks_shared_active_execution_capacity` --- codex-rs/core/src/agent/control.rs | 33 +++-- codex-rs/core/src/agent/control/execution.rs | 110 ++++++++++++++++ .../core/src/agent/control/execution_tests.rs | 60 +++++++++ codex-rs/core/src/agent/control/spawn.rs | 5 +- codex-rs/core/src/codex_thread.rs | 6 + codex-rs/core/src/session/session.rs | 7 +- codex-rs/core/src/session/tests.rs | 2 +- codex-rs/core/src/state/turn.rs | 2 + codex-rs/core/src/tasks/mod.rs | 5 + codex-rs/core/tests/suite/agent_execution.rs | 122 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + 11 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 codex-rs/core/src/agent/control/execution.rs create mode 100644 codex-rs/core/src/agent/control/execution_tests.rs create mode 100644 codex-rs/core/tests/suite/agent_execution.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index e9994883f5..888f9cdf94 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -42,10 +42,13 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; +pub(crate) use self::execution::AgentExecutionGuard; +use self::execution::AgentExecutionLimiter; use self::residency::V2Residency; const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; +mod execution; mod legacy; mod residency; mod spawn; @@ -95,6 +98,7 @@ pub(crate) struct AgentControl { manager: Weak, state: Arc, v2_residency: Arc, + agent_execution_limiter: Arc, } impl AgentControl { @@ -106,8 +110,9 @@ impl AgentControl { } } - pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self { + pub(crate) fn with_session_id(mut self, session_id: SessionId, max_threads: usize) -> Self { self.session_id = session_id; + self.agent_execution_limiter.initialize(max_threads); self } @@ -120,6 +125,19 @@ impl AgentControl { &self, agent_id: ThreadId, initial_operation: Op, + ) -> CodexResult { + let state = self.upgrade()?; + self.ensure_execution_capacity_for_op(agent_id, &initial_operation) + .await?; + self.send_input_after_capacity_check(agent_id, &state, initial_operation) + .await + } + + async fn send_input_after_capacity_check( + &self, + agent_id: ThreadId, + state: &Arc, + initial_operation: Op, ) -> CodexResult { let last_task_message = match &initial_operation { Op::InterAgentCommunication { communication } => { @@ -127,11 +145,10 @@ impl AgentControl { } _ => non_empty_task_message(render_input_preview(&initial_operation)), }; - let state = self.upgrade()?; let result = self .handle_thread_request_result( agent_id, - &state, + state, state.send_op(agent_id, initial_operation).await, ) .await; @@ -153,14 +170,10 @@ impl AgentControl { ) -> CodexResult { let last_task_message = last_task_message_from_communication(&communication); let state = self.upgrade()?; + let op = Op::InterAgentCommunication { communication }; + self.ensure_execution_capacity_for_op(agent_id, &op).await?; let result = self - .handle_thread_request_result( - agent_id, - &state, - state - .send_op(agent_id, Op::InterAgentCommunication { communication }) - .await, - ) + .handle_thread_request_result(agent_id, &state, state.send_op(agent_id, op).await) .await; if result.is_ok() { match last_task_message { diff --git a/codex-rs/core/src/agent/control/execution.rs b/codex-rs/core/src/agent/control/execution.rs new file mode 100644 index 0000000000..42aaa50e02 --- /dev/null +++ b/codex-rs/core/src/agent/control/execution.rs @@ -0,0 +1,110 @@ +use super::AgentControl; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; +use std::sync::Arc; +use std::sync::OnceLock; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +#[derive(Default)] +pub(super) struct AgentExecutionLimiter { + active: AtomicUsize, + max_threads: OnceLock, +} + +pub(crate) struct AgentExecutionGuard { + limiter: Arc, +} + +impl Drop for AgentExecutionGuard { + fn drop(&mut self) { + self.limiter.active.fetch_sub(1, Ordering::AcqRel); + } +} + +impl AgentControl { + pub(crate) async fn ensure_execution_capacity_for_op( + &self, + thread_id: ThreadId, + op: &Op, + ) -> CodexResult<()> { + if !op_starts_turn(op) { + return Ok(()); + } + let state = self.upgrade()?; + let thread = state.get_thread(thread_id).await?; + if thread.codex.session.active_turn.lock().await.is_some() { + return Ok(()); + } + let config = thread.codex.session.get_config().await; + let multi_agent_version = thread + .multi_agent_version() + .unwrap_or_else(|| config.multi_agent_version_from_features()); + self.ensure_execution_capacity(multi_agent_version, &thread.session_source) + } + + pub(crate) fn ensure_execution_capacity( + &self, + multi_agent_version: MultiAgentVersion, + session_source: &SessionSource, + ) -> CodexResult<()> { + if !is_execution_limited(multi_agent_version, session_source) { + return Ok(()); + } + let max_threads = self.agent_execution_limiter.max_threads(); + if self.agent_execution_limiter.has_capacity() { + Ok(()) + } else { + Err(CodexErr::AgentLimitReached { max_threads }) + } + } + + pub(crate) fn execution_guard( + &self, + multi_agent_version: MultiAgentVersion, + session_source: &SessionSource, + ) -> Option { + is_execution_limited(multi_agent_version, session_source) + .then(|| Arc::clone(&self.agent_execution_limiter).guard()) + } +} + +impl AgentExecutionLimiter { + pub(super) fn initialize(&self, max_threads: usize) { + self.max_threads.get_or_init(|| max_threads); + } + + fn max_threads(&self) -> usize { + self.max_threads.get().copied().unwrap_or(usize::MAX) + } + + fn has_capacity(&self) -> bool { + self.active.load(Ordering::Acquire) < self.max_threads() + } + + fn guard(self: Arc) -> AgentExecutionGuard { + self.active.fetch_add(1, Ordering::AcqRel); + AgentExecutionGuard { limiter: self } + } +} + +fn op_starts_turn(op: &Op) -> bool { + matches!(op, Op::UserInput { .. }) + || matches!(op, Op::InterAgentCommunication { communication } if communication.trigger_turn) +} + +fn is_execution_limited( + multi_agent_version: MultiAgentVersion, + session_source: &SessionSource, +) -> bool { + multi_agent_version == MultiAgentVersion::V2 + && matches!(session_source, SessionSource::SubAgent(_)) +} + +#[cfg(test)] +#[path = "execution_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/agent/control/execution_tests.rs b/codex-rs/core/src/agent/control/execution_tests.rs new file mode 100644 index 0000000000..9cad08f951 --- /dev/null +++ b/codex-rs/core/src/agent/control/execution_tests.rs @@ -0,0 +1,60 @@ +use crate::agent::AgentControl; +use codex_protocol::error::CodexErr; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use pretty_assertions::assert_eq; + +fn control_with_limit(max_threads: usize) -> AgentControl { + let control = AgentControl::default(); + control.agent_execution_limiter.initialize(max_threads); + control +} + +#[test] +fn execution_guards_count_active_v2_subagent_turns() { + let control = control_with_limit(/*max_threads*/ 1); + // Child role configs cannot replace the root-derived session limit. + control + .agent_execution_limiter + .initialize(/*max_threads*/ 2); + let source = SessionSource::SubAgent(SubAgentSource::Other("worker".to_string())); + + control + .ensure_execution_capacity(MultiAgentVersion::V2, &source) + .expect("first active turn should fit"); + let first = control + .execution_guard(MultiAgentVersion::V2, &source) + .expect("v2 subagent execution should be counted"); + let Err(err) = control.ensure_execution_capacity(MultiAgentVersion::V2, &source) else { + panic!("second active turn should exceed the derived non-root cap"); + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + drop(first); + control + .ensure_execution_capacity(MultiAgentVersion::V2, &source) + .expect("capacity should be released when the running task drops"); +} + +#[test] +fn execution_guards_ignore_root_and_v1_turns() { + let control = control_with_limit(/*max_threads*/ 0); + + assert!( + control + .execution_guard(MultiAgentVersion::V2, &SessionSource::Cli) + .is_none() + ); + assert!( + control + .execution_guard( + MultiAgentVersion::V1, + &SessionSource::SubAgent(SubAgentSource::Other("worker".to_string())), + ) + .is_none() + ); +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index e3536e89f9..a103fbb80d 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -207,6 +207,9 @@ impl AgentControl { &config, ) .await; + if let Some(session_source) = session_source.as_ref() { + self.ensure_execution_capacity(multi_agent_version, session_source)?; + } let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); let spawn_uses_v2_residency = multi_agent_version == MultiAgentVersion::V2 && session_source @@ -349,7 +352,7 @@ impl AgentControl { ) .await; - self.send_input(new_thread.thread_id, initial_operation) + self.send_input_after_capacity_check(new_thread.thread_id, &state, initial_operation) .await?; if multi_agent_version != MultiAgentVersion::V2 { let child_reference = agent_metadata diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index b3f9cb0e2e..1697f768e2 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -234,6 +234,12 @@ impl CodexThread { trace: Option, client_user_message_id: Option, ) -> CodexResult { + self.codex + .session + .services + .agent_control + .ensure_execution_capacity_for_op(self.session_configured.thread_id, &op) + .await?; self.codex .submit_user_input_with_client_user_message_id(op, trace, client_user_message_id) .await diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 122ff6aeb9..92f9737310 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -948,7 +948,12 @@ impl Session { } else { SessionId::from(thread_id) }; - let agent_control = agent_control.with_session_id(session_id); + let agent_control = agent_control.with_session_id( + session_id, + config + .effective_agent_max_threads(MultiAgentVersion::V2) + .unwrap_or(usize::MAX), + ); let session_extension_data = codex_extension_api::ExtensionData::new(session_id.to_string()); let thread_extension_data = diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 366cf6f22a..4d914ff985 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5184,7 +5184,7 @@ async fn resumed_subagent_session_keeps_inherited_session_id() { rollout_path: None, }), session_source, - AgentControl::default().with_session_id(parent_session_id), + AgentControl::default().with_session_id(parent_session_id, /*max_threads*/ usize::MAX), ) .await .expect("resume should succeed"); diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index eaf26ba853..d1e31b7994 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -18,6 +18,7 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles; use rmcp::model::RequestId; use tokio::sync::oneshot; +use crate::agent::control::AgentExecutionGuard; use crate::session::TurnInputQueue; use crate::session::turn_context::TurnContext; use crate::tasks::AnySessionTask; @@ -76,6 +77,7 @@ pub(crate) struct RunningTask { pub(crate) handle: AbortOnDropHandle<()>, pub(crate) turn_context: Arc, pub(crate) turn_extension_data: Arc, + pub(crate) _agent_execution_guard: Option, // Timer recorded when the task drops to capture the full turn duration. pub(crate) _timer: Option, } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index a9a664a505..911f30b319 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -359,6 +359,10 @@ impl Session { let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); debug_assert!(turn.task.is_none()); + let agent_execution_guard = self.services.agent_control.execution_guard( + turn_context.multi_agent_version, + &turn_context.session_source, + ); let done_clone = Arc::clone(&done); let session_ctx = Arc::new(SessionTaskContext::new( Arc::clone(self), @@ -430,6 +434,7 @@ impl Session { cancellation_token, turn_context: Arc::clone(&turn_context), turn_extension_data, + _agent_execution_guard: agent_execution_guard, _timer: timer, }; turn.task = Some(running_task); diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs new file mode 100644 index 0000000000..f073d70089 --- /dev/null +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -0,0 +1,122 @@ +use anyhow::Result; +use codex_features::Feature; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; + +const FIRST_PROMPT: &str = "spawn the first worker"; +const FIRST_TASK: &str = "first worker task"; +const SECOND_TASK: &str = "second worker task"; + +fn body_contains(request: &wiremock::Request, text: &str) -> bool { + serde_json::from_slice::(&request.body) + .is_ok_and(|body| body.to_string().contains(text)) +} + +fn has_function_call_output(request: &wiremock::Request, call_id: &str) -> bool { + serde_json::from_slice::(&request.body).is_ok_and(|body| { + body.get("input") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) + == Some("function_call_output") + && item.get("call_id").and_then(serde_json::Value::as_str) == Some(call_id) + }) + }) + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> { + let server = start_mock_server().await; + let first_args = serde_json::to_string(&json!({ + "message": FIRST_TASK, + "task_name": "first", + }))?; + mount_sse_once_match( + &server, + |request: &wiremock::Request| body_contains(request, FIRST_PROMPT), + sse(vec![ + ev_response_created("first-response"), + ev_function_call("first-call", "spawn_agent", &first_args), + ev_completed("first-response"), + ]), + ) + .await; + let second_args = serde_json::to_string(&json!({ + "message": SECOND_TASK, + "task_name": "second", + }))?; + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + body_contains(request, FIRST_TASK) && !has_function_call_output(request, "first-call") + }, + sse(vec![ + ev_response_created("first-worker-response"), + ev_function_call("second-call", "spawn_agent", &second_args), + ev_completed("first-worker-response"), + ]), + ) + .await; + let second_followup = mount_sse_once_match( + &server, + |request: &wiremock::Request| has_function_call_output(request, "second-call"), + sse(vec![ + ev_response_created("second-followup-response"), + ev_assistant_message("second-followup-message", "blocked"), + ev_completed("second-followup-response"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| has_function_call_output(request, "first-call"), + sse(vec![ + ev_response_created("first-followup-response"), + ev_assistant_message("first-followup-message", "spawned"), + ev_completed("first-followup-response"), + ]), + ) + .await; + + let mut builder = test_codex().with_model("koffing").with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; + }); + let test = builder.build(&server).await?; + test.submit_turn(FIRST_PROMPT).await?; + + let second_output = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(output) = second_followup.function_call_output_text("second-call") { + return output; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert_eq!( + second_output, + "collab spawn failed: agent thread limit reached" + ); + assert_eq!(test.thread_manager.list_thread_ids().await.len(), 2); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 11246d7cc4..234fb8a2f0 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -30,6 +30,7 @@ pub static CODEX_ALIASES_TEMP_DIR: Option = { #[cfg(not(target_os = "windows"))] mod abort_tasks; mod additional_context; +mod agent_execution; mod agent_jobs; mod agent_websocket; mod agents_md; From 8d415050fce4b4ebc6da1ba247379844235fa453 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 8 Jun 2026 14:46:35 +0200 Subject: [PATCH 110/141] Rename multi-agent v2 close_agent to interrupt_agent (#26994) ## Why `close_agent` is the wrong model-facing name for the v2 operation after the residency changes. V2 agents remain reusable by task name, and residency/unloading owns capacity management; the exposed tool should describe the action it actually performs: interrupt the target agent's current turn without making the agent unavailable for future messages or follow-up tasks. ## What changed - Rename the multi-agent v2 tool from `close_agent` to `interrupt_agent`. - Keep the v1 `close_agent` surface unchanged. - Update the v2 handler to send `Op::Interrupt`, keep interrupted agents registered, and reject root/self targets with interrupt-specific errors. - Route interrupt delivery through the existing dead-thread cleanup path so stale resident entries do not keep consuming capacity. - Update tool planning and handler tests for the new v2 surface and semantics. ## Verification Added focused coverage in: - `core/src/tools/spec_plan_tests.rs` - `core/src/tools/handlers/multi_agents_tests.rs` --- codex-rs/core/src/agent/control.rs | 13 +- .../src/tools/handlers/multi_agents_spec.rs | 20 +- .../src/tools/handlers/multi_agents_tests.rs | 231 +++++++++++++----- .../src/tools/handlers/multi_agents_v2.rs | 4 +- .../{close_agent.rs => interrupt_agent.rs} | 77 ++---- .../handlers/multi_agents_v2/message_tool.rs | 8 +- codex-rs/core/src/tools/spec_plan.rs | 4 +- codex-rs/core/src/tools/spec_plan_tests.rs | 12 +- 8 files changed, 239 insertions(+), 130 deletions(-) rename codex-rs/core/src/tools/handlers/multi_agents_v2/{close_agent.rs => interrupt_agent.rs} (56%) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 888f9cdf94..53a50e8e1f 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -189,7 +189,12 @@ impl AgentControl { /// Interrupt the current task for an existing agent thread. pub(crate) async fn interrupt_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; - state.send_op(agent_id, Op::Interrupt).await + self.handle_thread_request_result( + agent_id, + &state, + state.send_op(agent_id, Op::Interrupt).await, + ) + .await } async fn handle_thread_request_result( @@ -232,6 +237,12 @@ impl AgentControl { self.state.agent_metadata_for_thread(agent_id) } + pub(crate) fn ensure_agent_known(&self, agent_id: ThreadId) -> CodexResult { + self.state + .agent_metadata_for_thread(agent_id) + .ok_or(CodexErr::ThreadNotFound(agent_id)) + } + pub(crate) async fn list_live_agent_subtree_thread_ids( &self, agent_id: ThreadId, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 1dcb55f436..8455b13036 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -302,26 +302,30 @@ pub fn create_close_agent_tool_v1() -> ToolSpec { strict: false, defer_loading: None, parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())), - output_schema: Some(close_agent_output_schema()), + output_schema: Some(agent_previous_status_output_schema( + "The agent status observed before shutdown was requested.", + )), })], }) } -pub fn create_close_agent_tool_v2() -> ToolSpec { +pub fn create_interrupt_agent_tool_v2() -> ToolSpec { let properties = BTreeMap::from([( "target".to_string(), JsonSchema::string(Some( - "Agent id or canonical task name to close (from spawn_agent).".to_string(), + "Agent id or canonical task name to interrupt (from spawn_agent).".to_string(), )), )]); ToolSpec::Function(ResponsesApiTool { - name: "close_agent".to_string(), - description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Completed agents remain open and count toward the concurrency limit until closed. Don't keep agents open for too long if they are not needed anymore.".to_string(), + name: "interrupt_agent".to_string(), + description: "Interrupt an agent's current turn, if any, and return its previous status. The agent remains available for messages and follow-up tasks.".to_string(), strict: false, defer_loading: None, parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())), - output_schema: Some(close_agent_output_schema()), + output_schema: Some(agent_previous_status_output_schema( + "The agent status observed before the interrupt request was handled.", + )), }) } @@ -501,12 +505,12 @@ fn wait_output_schema_v2() -> Value { }) } -fn close_agent_output_schema() -> Value { +fn agent_previous_status_output_schema(previous_status_description: &str) -> Value { json!({ "type": "object", "properties": { "previous_status": { - "description": "The agent status observed before shutdown was requested.", + "description": previous_status_description, "allOf": [agent_status_output_schema()] } }, 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 67ccf36f91..4f514e23a7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -9,8 +9,8 @@ use crate::session::tests::make_session_and_context; use crate::session_prefix::format_subagent_notification_message; use crate::thread_manager::thread_store_from_config; use crate::tools::context::ToolOutput; -use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2; +use crate::tools::handlers::multi_agents_v2::InterruptAgentHandler; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; @@ -178,6 +178,11 @@ struct ListedAgentResult { last_task_message: Option, } +#[derive(Debug, Deserialize)] +struct InterruptAgentResult { + previous_status: AgentStatus, +} + #[tokio::test] async fn handler_rejects_non_function_payloads() { let (session, turn) = make_session_and_context().await; @@ -1744,6 +1749,82 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { ); } +#[tokio::test] +async fn multi_agent_v2_list_agents_keeps_interrupted_resident_agents() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.thread_id = root.thread_id; + let mut config = (*turn.config).clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + set_turn_config(&mut turn, config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker" + })), + )) + .await + .expect("spawn_agent should succeed"); + let _ = expect_text_output(spawn_output); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + let agent_path = session + .services + .agent_control + .get_agent_metadata(agent_id) + .expect("worker metadata should exist") + .agent_path + .expect("worker path should exist"); + let interrupt_output = InterruptAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "interrupt_agent", + function_payload(json!({"target": "worker"})), + )) + .await + .expect("interrupt_agent should succeed"); + let _ = expect_text_output(interrupt_output); + + let output = ListAgentsHandlerV2 + .handle(invocation( + session, + turn, + "list_agents", + function_payload(json!({})), + )) + .await + .expect("list_agents should succeed"); + let (content, _) = expect_text_output(output); + let result: ListAgentsResult = + serde_json::from_str(&content).expect("list_agents result should be json"); + + assert_eq!(result.agents.len(), 2); + assert_eq!(result.agents[0].agent_name, "/root"); + assert_eq!( + result.agents[0].last_task_message.as_deref(), + Some("Main thread") + ); + assert_eq!(result.agents[1].agent_name, agent_path.as_str()); +} + #[tokio::test] async fn multi_agent_v2_send_message_rejects_legacy_items_field() { let (mut session, mut turn) = make_session_and_context().await; @@ -3699,7 +3780,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { } #[tokio::test] -async fn multi_agent_v2_close_agent_accepts_task_name_target() { +async fn multi_agent_v2_interrupt_agent_accepts_task_name_target() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -3736,29 +3817,77 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker path should resolve"); - - let output = CloseAgentHandlerV2 + let worker_thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should be resident"); + let worker_session = worker_thread.codex.session.clone(); + SpawnAgentHandlerV2::default() .handle(invocation( - session, - turn, - "close_agent", + worker_session.clone(), + worker_session.new_default_turn().await, + "spawn_agent", + function_payload(json!({ + "message": "inspect a child task", + "task_name": "child" + })), + )) + .await + .expect("child spawn should succeed"); + let child_id = session + .services + .agent_control + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker/child") + .await + .expect("child path should resolve"); + + let output = InterruptAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "interrupt_agent", function_payload(json!({"target": "worker"})), )) .await - .expect("close_agent should succeed for v2 task names"); + .expect("interrupt_agent should succeed for v2 task names"); let (content, success) = expect_text_output(output); - let result: close_agent::CloseAgentResult = - serde_json::from_str(&content).expect("close_agent result should be json"); + let result: InterruptAgentResult = + serde_json::from_str(&content).expect("interrupt_agent result should be json"); assert_ne!(result.previous_status, AgentStatus::NotFound); assert_eq!(success, Some(true)); assert_eq!( - manager.agent_control().get_status(agent_id).await, - AgentStatus::NotFound + session + .services + .agent_control + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") + .await + .expect("worker path should remain resolvable"), + agent_id + ); + manager + .get_thread(agent_id) + .await + .expect("worker should remain resident"); + manager + .get_thread(child_id) + .await + .expect("child should remain resident"); + let ops = manager.captured_ops(); + assert!( + ops.iter() + .any(|(thread_id, op)| *thread_id == agent_id && matches!(op, Op::Interrupt)) + ); + assert!(!ops.iter().any(|(thread_id, op)| { + (*thread_id == agent_id || *thread_id == child_id) && matches!(op, Op::Shutdown) + })); + assert!( + !ops.iter() + .any(|(thread_id, op)| *thread_id == child_id && matches!(op, Op::Interrupt)) ); } #[tokio::test] -async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { +async fn multi_agent_v2_interrupt_agent_accepts_unloaded_task_name_target() { let (mut session, mut turn) = make_session_and_context().await; let mut config = (*turn.config).clone(); config.multi_agent_v2.max_concurrent_threads_per_session = 2; @@ -3819,18 +3948,18 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { .expect("removed worker thread should still accept shutdown"); stale_thread.wait_until_terminated().await; - let output = CloseAgentHandlerV2 + let output = InterruptAgentHandler .handle(invocation( session.clone(), turn.clone(), - "close_agent", + "interrupt_agent", function_payload(json!({"target": "worker"})), )) .await - .expect("close_agent should reap stale v2 task names"); + .expect("interrupt_agent should accept unloaded v2 task names"); let (content, success) = expect_text_output(output); - let result: close_agent::CloseAgentResult = - serde_json::from_str(&content).expect("close_agent result should be json"); + let result: InterruptAgentResult = + serde_json::from_str(&content).expect("interrupt_agent result should be json"); assert_eq!(result.previous_status, AgentStatus::NotFound); assert_eq!(success, Some(true)); @@ -3841,7 +3970,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { ) .await .expect("open children should load"); - assert_eq!(open_children, Vec::::new()); + assert_eq!(open_children, vec![agent_id]); let closed_children = state_db .list_thread_spawn_children_with_status( root.thread_id, @@ -3849,36 +3978,26 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { ) .await .expect("closed children should load"); - assert_eq!(closed_children, vec![agent_id]); + assert_eq!(closed_children, Vec::::new()); - SpawnAgentHandlerV2::default() + let output = ListAgentsHandlerV2 .handle(invocation( session.clone(), turn.clone(), - "spawn_agent", - function_payload(json!({ - "message": "inspect this repo again", - "task_name": "replacement" - })), + "list_agents", + function_payload(json!({})), )) .await - .expect("spawn_agent should succeed after stale close releases the slot"); - let replacement_id = session - .services - .agent_control - .resolve_agent_reference(session.thread_id, &turn.session_source, "replacement") - .await - .expect("replacement path should resolve"); - let _ = session - .services - .agent_control - .shutdown_live_agent(replacement_id) - .await - .expect("replacement should shut down"); + .expect("list_agents should succeed"); + let (content, _) = expect_text_output(output); + let result: ListAgentsResult = + serde_json::from_str(&content).expect("list_agents result should be json"); + assert_eq!(result.agents.len(), 1); + assert_eq!(result.agents[0].agent_name, "/root"); } #[tokio::test] -async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { +async fn multi_agent_v2_interrupt_agent_rejects_root_target_and_id() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -3896,31 +4015,31 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { let session = Arc::new(session); let turn = Arc::new(turn); - let root_path_error = CloseAgentHandlerV2 + let root_path_error = InterruptAgentHandler .handle(invocation( session.clone(), turn.clone(), - "close_agent", + "interrupt_agent", function_payload(json!({"target": "/root"})), )) .await .err() - .expect("close_agent should reject the root path"); + .expect("interrupt_agent should reject the root path"); assert_eq!( root_path_error, FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) ); - let root_id_error = CloseAgentHandlerV2 + let root_id_error = InterruptAgentHandler .handle(invocation( session, turn, - "close_agent", + "interrupt_agent", function_payload(json!({"target": root.thread_id.to_string()})), )) .await .err() - .expect("close_agent should reject the root thread id"); + .expect("interrupt_agent should reject the root thread id"); assert_eq!( root_id_error, FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) @@ -3928,7 +4047,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { } #[tokio::test] -async fn multi_agent_v2_close_agent_rejects_self_target_by_id() { +async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_id() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let mut config = (*turn.config).clone(); @@ -3976,27 +4095,27 @@ async fn multi_agent_v2_close_agent_rejects_self_target_by_id() { agent_role: None, }); - let err = CloseAgentHandlerV2 + let err = InterruptAgentHandler .handle(invocation( Arc::new(session), Arc::new(turn), - "close_agent", + "interrupt_agent", function_payload(json!({"target": child_thread_id.to_string()})), )) .await .err() - .expect("close_agent should reject self-target by id"); + .expect("interrupt_agent should reject self-target by id"); assert_eq!( err, FunctionCallError::RespondToModel( - "an agent cannot close itself; return your result and let the parent close you if needed" + "an agent cannot interrupt itself; return your result and let the parent interrupt you if needed" .to_string() ) ); } #[tokio::test] -async fn multi_agent_v2_close_agent_rejects_self_target_by_task_name() { +async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_task_name() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let mut config = (*turn.config).clone(); @@ -4044,20 +4163,20 @@ async fn multi_agent_v2_close_agent_rejects_self_target_by_task_name() { agent_role: None, }); - let err = CloseAgentHandlerV2 + let err = InterruptAgentHandler .handle(invocation( Arc::new(session), Arc::new(turn), - "close_agent", + "interrupt_agent", function_payload(json!({"target": child_path.to_string()})), )) .await .err() - .expect("close_agent should reject self-target by task name"); + .expect("interrupt_agent should reject self-target by task name"); assert_eq!( err, FunctionCallError::RespondToModel( - "an agent cannot close itself; return your result and let the parent close you if needed" + "an agent cannot interrupt itself; return your result and let the parent interrupt you if needed" .to_string() ) ); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 8bb207e3e1..6b5bd9f241 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -29,15 +29,15 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; -pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use followup_task::Handler as FollowupTaskHandler; +pub(crate) use interrupt_agent::Handler as InterruptAgentHandler; pub(crate) use list_agents::Handler as ListAgentsHandler; pub(crate) use send_message::Handler as SendMessageHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; -mod close_agent; mod followup_task; +mod interrupt_agent; mod list_agents; mod message_tool; mod send_message; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs similarity index 56% rename from codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs rename to codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs index 7b3f8d776e..907367348b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs @@ -1,5 +1,5 @@ use super::*; -use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; +use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; @@ -9,24 +9,26 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { fn tool_name(&self) -> ToolName { - ToolName::plain("close_agent") + ToolName::plain("interrupt_agent") } fn spec(&self) -> ToolSpec { - create_close_agent_tool_v2() + create_interrupt_agent_tool_v2() } async fn handle( &self, invocation: ToolInvocation, ) -> Result, FunctionCallError> { - handle_close_agent(invocation).await.map(boxed_tool_output) + handle_interrupt_agent(invocation) + .await + .map(boxed_tool_output) } } -async fn handle_close_agent( +async fn handle_interrupt_agent( invocation: ToolInvocation, -) -> Result { +) -> Result { let ToolInvocation { session, turn, @@ -35,11 +37,13 @@ async fn handle_close_agent( .. } = invocation; let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; + let args: InterruptAgentArgs = parse_arguments(&arguments)?; let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session.services.agent_control.get_agent_metadata(agent_id); - let known_agent = receiver_agent.is_some(); - let receiver_agent = receiver_agent.unwrap_or_default(); + let receiver_agent = session + .services + .agent_control + .ensure_agent_known(agent_id) + .map_err(|err| collab_agent_error(agent_id, err))?; if receiver_agent .agent_path .as_ref() @@ -51,7 +55,7 @@ async fn handle_close_agent( } if agent_id == session.thread_id { return Err(FunctionCallError::RespondToModel( - "an agent cannot close itself; return your result and let the parent close you if needed" + "an agent cannot interrupt itself; return your result and let the parent interrupt you if needed" .to_string(), )); } @@ -67,43 +71,16 @@ async fn handle_close_agent( .into(), ) .await; - let status = match session + let status = session.services.agent_control.get_status(agent_id).await; + let result = match session .services .agent_control - .subscribe_status(agent_id) + .interrupt_agent(agent_id) .await { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(CodexErr::ThreadNotFound(_)) if known_agent => { - session.services.agent_control.get_status(agent_id).await - } - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } + Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => Ok(()), + Err(err) => Err(collab_agent_error(agent_id, err)), }; - let result = session - .services - .agent_control - .close_agent(agent_id) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); session .send_event( &turn, @@ -121,7 +98,7 @@ async fn handle_close_agent( .await; result?; - Ok(CloseAgentResult { + Ok(InterruptAgentResult { previous_status: status, }) } @@ -134,18 +111,18 @@ impl CoreToolRuntime for Handler { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct CloseAgentArgs { +struct InterruptAgentArgs { target: String, } #[derive(Debug, Deserialize, Serialize)] -pub(crate) struct CloseAgentResult { +pub(crate) struct InterruptAgentResult { pub(crate) previous_status: AgentStatus, } -impl ToolOutput for CloseAgentResult { +impl ToolOutput for InterruptAgentResult { fn log_preview(&self) -> String { - tool_output_json_text(self, "close_agent") + tool_output_json_text(self, "interrupt_agent") } fn success_for_logging(&self) -> bool { @@ -153,10 +130,10 @@ impl ToolOutput for CloseAgentResult { } fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, Some(true), "close_agent") + tool_output_response_item(call_id, payload, self, Some(true), "interrupt_agent") } fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "close_agent") + tool_output_code_mode_result(self, "interrupt_agent") } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 528fa55264..07fd9d70ee 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -74,12 +74,8 @@ pub(crate) async fn handle_message_string_tool( let receiver_agent = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .ok_or_else(|| { - FunctionCallError::RespondToModel(format!( - "agent with id {receiver_thread_id} not found" - )) - })?; + .ensure_agent_known(receiver_thread_id) + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; if mode == MessageDeliveryMode::TriggerTurn && receiver_agent .agent_path diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index b1a01cd7aa..fed62a0ee1 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -37,8 +37,8 @@ use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2; +use crate::tools::handlers::multi_agents_v2::InterruptAgentHandler; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; @@ -742,7 +742,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu exposure, )); planned_tools.add_arc(override_tool_exposure( - multi_agent_v2_handler(CloseAgentHandlerV2, tool_namespace), + multi_agent_v2_handler(InterruptAgentHandler, tool_namespace), exposure, )); planned_tools.add_arc(override_tool_exposure( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 5fe811b19e..f96f41f0f2 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -959,6 +959,7 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { "resume_agent", "wait_agent", "close_agent", + "interrupt_agent", "send_message", "followup_task", "assign_task", @@ -1011,10 +1012,10 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { "send_message", "followup_task", "wait_agent", - "close_agent", + "interrupt_agent", "list_agents", ]); - v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task"]); + v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task", "close_agent"]); let spawn_agent_description = match v2.visible_spec("spawn_agent") { ToolSpec::Function(tool) => tool.description.as_str(), other => panic!("expected spawn_agent function spec, got {other:?}"), @@ -1096,6 +1097,7 @@ async fn v1_multi_agent_tools_defer_when_tool_search_available() { "resume_agent", "wait_agent", "close_agent", + "interrupt_agent", ]); for tool_name in [ "spawn_agent", @@ -1154,7 +1156,7 @@ async fn multi_agent_v2_can_use_configured_tool_namespace() { "send_message", "followup_task", "wait_agent", - "close_agent", + "interrupt_agent", "list_agents", ] { namespaced.assert_visible_lacks(&[tool_name]); @@ -1244,7 +1246,7 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { "send_message", "followup_task", "wait_agent", - "close_agent", + "interrupt_agent", "list_agents", ] { assert!( @@ -1317,7 +1319,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { "send_message", "followup_task", "wait_agent", - "close_agent", + "interrupt_agent", "list_agents", // Hosted Responses tools. "web_search", From 0526cb56ac3501a02968010d03873993c319e290 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 8 Jun 2026 16:44:50 +0200 Subject: [PATCH 111/141] Avoid reopening v2 descendants on resume (#26997) ## Why Multi-agent v2 residency is intended to keep only the threads that need to be live. The existing rollout resume path still walked persisted open descendants and reopened the entire descendant tree when resuming a v2 root, which turns resume into an eager reload of work that should stay unloaded until it is explicitly needed. The interrupted-agent path has a related residency issue. Interrupted agents remain open by design, so an idle interrupted resident should be eligible for eviction just like an idle completed or errored resident. Otherwise a resident set full of interrupted agents can consume every v2 slot and block later spawns or reloads with `AgentLimitReached`. ## What Changed - Return early from `resume_agent_from_rollout` after resuming a v2 thread so persisted v2 descendants are not reopened eagerly. - Treat idle `Interrupted` v2 residents as unloadable in the LRU residency path. - Add focused coverage for v2 root resume leaving descendants unloaded and for eviction of an idle interrupted v2 resident when a new slot is needed. ## Verification Added targeted `codex-core` tests covering: - v2 root resume with persisted descendants, verifying only the root is loaded after resume. - residency eviction of an idle interrupted v2 agent when the resident set is full. --- codex-rs/core/src/agent/control/residency.rs | 6 +- .../core/src/agent/control/residency_tests.rs | 86 +++++++++++++ codex-rs/core/src/agent/control/spawn.rs | 19 +-- codex-rs/core/src/agent/control_tests.rs | 119 +++++++++++++++--- 4 files changed, 203 insertions(+), 27 deletions(-) diff --git a/codex-rs/core/src/agent/control/residency.rs b/codex-rs/core/src/agent/control/residency.rs index 8056fedeba..901f2c93bd 100644 --- a/codex-rs/core/src/agent/control/residency.rs +++ b/codex-rs/core/src/agent/control/residency.rs @@ -136,9 +136,9 @@ impl V2Residency { continue; } candidate_thread.ensure_rollout_materialized().await; - if let Err(err) = candidate_thread.flush_rollout().await { + if let Err(err) = candidate_thread.shutdown_and_wait().await { warn!( - "failed to flush v2 resident thread before unloading {candidate_thread_id}: {err}" + "failed to shut down v2 resident thread before unloading {candidate_thread_id}: {err}" ); self.touch(candidate_thread_id); continue; @@ -225,7 +225,7 @@ pub(super) fn is_v2_resident_session_source(session_source: &SessionSource) -> b async fn is_unloadable(thread: &CodexThread) -> bool { matches!( thread.agent_status().await, - AgentStatus::Completed(_) | AgentStatus::Errored(_) + AgentStatus::Completed(_) | AgentStatus::Errored(_) | AgentStatus::Interrupted ) && thread.codex.session.active_turn.lock().await.is_none() && !thread .codex diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index a0338eda06..88d4b004d3 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -12,6 +12,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -62,6 +64,68 @@ async fn residency_slot_reservation_unloads_oldest_idle_v2_agent() { assert!(manager.get_thread(second.thread_id).await.is_ok()); } +#[tokio::test] +async fn interrupted_v2_agent_is_lost_after_residency_eviction() { + let mut config = test_config().await; + let _ = config.features.enable(Feature::MultiAgentV2); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; + let temp_home = tempfile::tempdir().expect("create temp home"); + config.codex_home = temp_home.path().to_path_buf().try_into().unwrap(); + config.cwd = temp_home.path().to_path_buf().try_into().unwrap(); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let root = manager + .start_thread(config.clone()) + .await + .expect("start root thread"); + let control = manager.agent_control(); + let state = control.upgrade().expect("thread manager should be live"); + + let first_slot = control + .reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None) + .await + .expect("first resident slot"); + let first = + spawn_v2_subagent(&control, &state, config.clone(), root.thread_id, "worker-1").await; + first_slot.commit(first.thread_id); + mark_thread_interrupted(first.thread.as_ref()).await; + + let second_slot = control + .reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None) + .await + .expect("second resident slot should evict the first interrupted idle agent"); + match manager.get_thread(first.thread_id).await { + Err(CodexErr::ThreadNotFound(thread_id)) => assert_eq!(thread_id, first.thread_id), + Err(err) => panic!("expected evicted thread to be missing, got {err:?}"), + Ok(_) => panic!("expected evicted thread to be missing"), + } + let second = + spawn_v2_subagent(&control, &state, config.clone(), root.thread_id, "worker-2").await; + second_slot.commit(second.thread_id); + mark_thread_completed(second.thread.as_ref()).await; + + let err = control + .ensure_v2_agent_loaded(config, first.thread_id) + .await + .expect_err("evicted interrupted agent should stay lost"); + match err { + CodexErr::ThreadNotFound(thread_id) => assert_eq!(thread_id, first.thread_id), + err => panic!("expected ThreadNotFound, got {err:?}"), + } + + assert!(manager.get_thread(root.thread_id).await.is_ok()); + assert!(manager.get_thread(second.thread_id).await.is_ok()); + match manager.get_thread(first.thread_id).await { + Err(CodexErr::ThreadNotFound(thread_id)) => assert_eq!(thread_id, first.thread_id), + Err(err) => panic!("expected evicted thread to be missing, got {err:?}"), + Ok(_) => panic!("expected evicted thread to be missing"), + } +} + async fn spawn_v2_subagent( control: &AgentControl, state: &Arc, @@ -102,6 +166,28 @@ async fn mark_thread_completed(thread: &CodexThread) { }), ) .await; + clear_active_turn(thread).await; +} + +async fn mark_thread_interrupted(thread: &CodexThread) { + let turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .send_event( + turn.as_ref(), + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn.sub_id.clone()), + reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + }), + ) + .await; + clear_active_turn(thread).await; +} + +async fn clear_active_turn(thread: &CodexThread) { // The fixture has no task runner to clear the turn after the terminal event. *thread.codex.session.active_turn.lock().await = None; } diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index a103fbb80d..a0d52ea4d6 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -527,13 +527,16 @@ impl AgentControl { session_source: SessionSource, ) -> CodexResult { let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); - let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - thread_id, - session_source, - )) + let (resumed_thread_id, resumed_multi_agent_version) = Box::pin( + self.resume_single_agent_from_rollout(config.clone(), thread_id, session_source), + ) .await?; let state = self.upgrade()?; + if config.multi_agent_version_from_features() == MultiAgentVersion::V2 + || resumed_multi_agent_version == MultiAgentVersion::V2 + { + return Ok(resumed_thread_id); + } let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { return Ok(resumed_thread_id); }; @@ -579,7 +582,7 @@ impl AgentControl { )) .await { - Ok(_) => true, + Ok((_, _)) => true, Err(err) => { warn!("failed to resume descendant thread {child_thread_id}: {err}"); false @@ -600,7 +603,7 @@ impl AgentControl { config: Config, thread_id: ThreadId, session_source: SessionSource, - ) -> CodexResult { + ) -> CodexResult<(ThreadId, MultiAgentVersion)> { let state = self.upgrade()?; let state_db_ctx = state.state_db(); let stored_thread = state @@ -705,6 +708,6 @@ impl AgentControl { ) .await; - Ok(resumed_thread.thread_id) + Ok((resumed_thread.thread_id, multi_agent_version)) } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index c9cd444925..3ad1360e5a 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -104,6 +104,10 @@ struct AgentControlHarness { impl AgentControlHarness { async fn new() -> Self { let (home, config) = test_config().await; + Self::new_with_config(home, config).await + } + + async fn new_with_config(home: TempDir, config: Config) -> Self { let state_db = init_state_db(&config).await; let manager = ThreadManager::with_models_provider_home_and_state_for_tests( CodexAuth::from_api_key("dummy"), @@ -249,6 +253,14 @@ async fn wait_for_live_thread_spawn_children( .expect("expected persisted child tree"); } +async fn assert_thread_not_loaded(manager: &ThreadManager, thread_id: ThreadId) { + match manager.get_thread(thread_id).await { + Err(CodexErr::ThreadNotFound(id)) => assert_eq!(id, thread_id), + Err(err) => panic!("expected ThreadNotFound, got {err:?}"), + Ok(_) => panic!("expected thread not to be loaded"), + } +} + #[tokio::test] async fn send_input_errors_when_manager_dropped() { let control = AgentControl::default(); @@ -532,22 +544,7 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { let (home, mut config) = test_config().await; let _ = config.features.enable(Feature::MultiAgentV2); let _ = config.features.enable(Feature::Sqlite); - let state_db = init_state_db(&config).await; - let manager = ThreadManager::with_models_provider_home_and_state_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - state_db.clone(), - ); - let control = manager.agent_control(); - let harness = AgentControlHarness { - _home: home, - config, - state_db, - manager, - control, - }; + let harness = AgentControlHarness::new_with_config(home, config).await; let (parent_thread_id, _parent_thread) = harness.start_thread().await; let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); let spawned_agent = harness @@ -634,6 +631,96 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn resume_agent_from_rollout_does_not_reopen_v2_descendants() { + let (home, mut config) = test_config().await; + let _ = config.features.enable(Feature::MultiAgentV2); + let _ = config.features.enable(Feature::Sqlite); + let harness = AgentControlHarness::new_with_config(home, config).await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let worker_path = AgentPath::root().join("worker").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let reviewer_path = worker_path.join("reviewer").expect("reviewer path"); + let reviewer_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello reviewer"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(reviewer_path.clone()), + agent_nickname: None, + agent_role: Some("reviewer".to_string()), + })), + ) + .await + .expect("reviewer spawn should succeed"); + + let worker_thread = harness + .manager + .get_thread(worker_thread_id) + .await + .expect("worker thread should exist"); + let reviewer_thread = harness + .manager + .get_thread(reviewer_thread_id) + .await + .expect("reviewer thread should exist"); + persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; + persist_thread_for_tree_resume(&worker_thread, "worker persisted").await; + persist_thread_for_tree_resume(&reviewer_thread, "reviewer persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[worker_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, worker_thread_id, &[reviewer_thread_id]) + .await; + + let report = harness + .manager + .shutdown_all_threads_bounded(Duration::from_secs(5)) + .await; + assert_eq!(report.submit_failed, Vec::::new()); + assert_eq!(report.timed_out, Vec::::new()); + + let resumed_manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + harness.config.model_provider.clone(), + harness.config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + harness.state_db.clone(), + ); + let resumed_control = resumed_manager.agent_control(); + let resumed_parent_thread_id = resumed_control + .resume_agent_from_rollout( + harness.config.clone(), + parent_thread_id, + SessionSource::Exec, + ) + .await + .expect("v2 root resume should succeed"); + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_ne!( + resumed_control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + assert_thread_not_loaded(&resumed_manager, worker_thread_id).await; + assert_thread_not_loaded(&resumed_manager, reviewer_thread_id).await; +} + #[tokio::test] async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { let harness = AgentControlHarness::new().await; From 26d932983398147e4443bd655ce24a6ce6833a1c Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 8 Jun 2026 09:53:04 -0700 Subject: [PATCH 112/141] [codex] Exclude external tool output from memories (#26821) ## Summary - add contains_external_context() to tool output so other tools can be opted out of influencing memory when disable_on_external_context=true - Classify standalone web-search output as external context (to match behavior as hosted web search) - Verify with integration test --- codex-rs/core/src/tools/registry.rs | 11 +++ codex-rs/core/tests/suite/sqlite_state.rs | 88 +++++++++++++++++++++++ codex-rs/ext/web-search/src/output.rs | 6 +- codex-rs/tools/src/tool_output.rs | 10 +++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1cc5e67eb5..20e08e9f7d 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -28,6 +28,7 @@ use codex_extension_api::ToolCallOutcome; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; +use codex_rollout::state_db; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; @@ -696,6 +697,16 @@ async fn handle_any_tool( let call_id = invocation.call_id.clone(); let payload = invocation.payload.clone(); let output = tool.handle(invocation.clone()).await?; + if output.contains_external_context() + && invocation.turn.config.memories.disable_on_external_context + { + state_db::mark_thread_memory_mode_polluted( + invocation.session.services.state_db.as_deref(), + invocation.session.thread_id, + "tool_output", + ) + .await; + } let post_tool_use_payload = CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref()); Ok(AnyToolResult { diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 9f8c940a0f..eb9f11a86d 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -1,8 +1,12 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; +use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; +use codex_login::CodexAuth; use codex_protocol::ThreadId; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -15,6 +19,7 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::UserInput; +use codex_web_search_extension::install as install_web_search_extension; use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -34,9 +39,14 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; use std::fs; +use std::sync::Arc; use tokio::time::Duration; use tracing_subscriber::prelude::*; use uuid::Uuid; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn new_thread_is_recorded_in_state_db() -> Result<()> { @@ -384,6 +394,84 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn standalone_web_search_marks_thread_memory_mode_polluted_when_configured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + Mock::given(method("POST")) + .and(path("/v1/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output": "Search result", + }))) + .expect(1) + .mount(&server) + .await; + mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + "web-run-1", + "web", + "run", + &json!({ + "search_query": [{"q": "standalone web search"}], + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let auth = CodexAuth::from_api_key("dummy"); + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); + let mut extension_builder = ExtensionRegistryBuilder::::new(); + install_web_search_extension(&mut extension_builder, auth_manager); + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(Arc::new(extension_builder.build())) + .with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::StandaloneWebSearch) + .expect("standalone web search should be enabled"); + config.memories.disable_on_external_context = true; + config + .web_search_mode + .set(WebSearchMode::Live) + .expect("web search mode should be accepted"); + }); + let test = builder.build(&server).await?; + let db = test.codex.state_db().expect("state db enabled"); + let thread_id = test.session_configured.thread_id; + + test.submit_turn("search the web").await?; + + let mut memory_mode = None; + for _ in 0..100 { + memory_mode = db.get_thread_memory_mode(thread_id).await?; + if memory_mode.as_deref() == Some("polluted") { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + assert_eq!(memory_mode.as_deref(), Some("polluted")); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/web-search/src/output.rs b/codex-rs/ext/web-search/src/output.rs index 799897b62d..543afdf7a9 100644 --- a/codex-rs/ext/web-search/src/output.rs +++ b/codex-rs/ext/web-search/src/output.rs @@ -23,9 +23,11 @@ impl ToolOutput for SearchOutput { true } + fn contains_external_context(&self) -> bool { + true + } + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - // TODO: Make standalone search honor memories.disable_on_external_context, - // as hosted web search does. ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index b61bb80de8..fe57f23ce9 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -18,6 +18,12 @@ pub trait ToolOutput: Send { fn success_for_logging(&self) -> bool; + /// Whether this output contains external context that should disable memory generation when + /// `memories.disable_on_external_context` is enabled. + fn contains_external_context(&self) -> bool { + false + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; /// Returns the tool call id exposed to `PostToolUse` hooks for this output. @@ -58,6 +64,10 @@ where (**self).success_for_logging() } + fn contains_external_context(&self) -> bool { + (**self).contains_external_context() + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { (**self).to_response_item(call_id, payload) } From 6d0e313e237b6c1dd055fc9b1d7469961e8c02f8 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:16:36 -0700 Subject: [PATCH 113/141] [codex] Restore release symbol artifacts with line tables (#26202) ## Summary - Restore separate release symbol archives for macOS, Linux, and Windows binaries. - Build release binaries with `line-tables-only` debuginfo instead of full debuginfo. - Strip Unix distribution binaries after extracting symbols, preserve Windows PDBs, and keep symbol archives available to the release job. - Strip the packaged Linux `bwrap` binary before hashing it so the embedded digest matches the distributed bytes. ## Root cause The first symbol-artifact implementation enabled `CARGO_PROFILE_RELEASE_DEBUG=full`. In the June 2 release runs, macOS ARM primary builds reached the 90-minute timeout while still inside `Cargo build`. After the symbol changes were reverted, the same primary build completed in about 22 minutes. The archive step itself completed in tens of seconds when reached. Rust's `line-tables-only` debuginfo level preserves function names and source locations for symbolication without emitting the heavier variable and type information from full debuginfo. ## Validation - Ran `just fmt` from `codex-rs`. - Ran `just test-github-scripts` from the repository root: 23 tests passed. - Ran `bash -n` and `shellcheck` on `.github/scripts/archive-release-symbols-and-strip-binaries.sh`. - Parsed both modified workflows as YAML and ran `git diff --check`. - Built a macOS release smoke binary with `line-tables-only`, archived its dSYM through the restored script, stripped the production binary, and verified that `atos` resolves `symbol_smoke_function` to `main.rs:2`. - Ran Linux archive-script control-flow coverage with stubbed `objcopy` and `strip` commands. - Ran Windows PDB archive staging coverage and verified underscore-emitted Rust PDB names are staged under shipped hyphenated binary names. ## Follow-up The release workflow only runs for tags or manual dispatches, so CI cannot dry-run the full release matrix on this PR. The next release run will verify runner time and memory behavior under `line-tables-only`. --- ...hive-release-symbols-and-strip-binaries.sh | 119 ++++++++++++++++++ .github/workflows/rust-release-windows.yml | 33 ++++- .github/workflows/rust-release.yml | 39 +++++- codex-rs/Cargo.toml | 7 +- 4 files changed, 192 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/archive-release-symbols-and-strip-binaries.sh diff --git a/.github/scripts/archive-release-symbols-and-strip-binaries.sh b/.github/scripts/archive-release-symbols-and-strip-binaries.sh new file mode 100755 index 0000000000..3e5894bb99 --- /dev/null +++ b/.github/scripts/archive-release-symbols-and-strip-binaries.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: archive-release-symbols-and-strip-binaries.sh \ + --target \ + --artifact-name \ + --release-dir \ + --archive-dir \ + --binaries "" +EOF +} + +target="" +artifact_name="" +release_dir="" +archive_dir="" +binaries="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:?--target requires a value}" + shift 2 + ;; + --artifact-name) + artifact_name="${2:?--artifact-name requires a value}" + shift 2 + ;; + --release-dir) + release_dir="${2:?--release-dir requires a value}" + shift 2 + ;; + --archive-dir) + archive_dir="${2:?--archive-dir requires a value}" + shift 2 + ;; + --binaries) + binaries="${2:?--binaries requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then + usage >&2 + exit 1 +fi + +symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}" +symbols_dir="${symbols_root}/codex-symbols-${artifact_name}" +archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz" +rm -rf "$symbols_root" +mkdir -p "$symbols_dir" "$archive_dir" +read -r -a binary_names <<< "$binaries" + +case "$target" in + *apple-darwin) + for binary in "${binary_names[@]}"; do + binary_path="${release_dir%/}/${binary}" + dsym_path="${binary_path}.dSYM" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" >&2 + exit 1 + fi + if [[ ! -d "$dsym_path" ]]; then + echo "dSYM $dsym_path not found" >&2 + exit 1 + fi + + cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM" + strip -S -x "$binary_path" + done + ;; + *linux*) + objcopy_bin="${OBJCOPY:-objcopy}" + strip_bin="${STRIP:-strip}" + for binary in "${binary_names[@]}"; do + binary_path="${release_dir%/}/${binary}" + debug_path="${symbols_dir}/${binary}.debug" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" >&2 + exit 1 + fi + + "$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path" + "$strip_bin" --strip-debug --strip-unneeded "$binary_path" + "$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path" + done + ;; + *windows*) + for binary in "${binary_names[@]}"; do + pdb_path="${release_dir%/}/${binary}.pdb" + if [[ ! -f "$pdb_path" ]]; then + echo "PDB $pdb_path not found" >&2 + exit 1 + fi + + cp "$pdb_path" "${symbols_dir}/${binary}.pdb" + done + ;; + *) + echo "No symbols packaging support for target: $target" >&2 + exit 1 + ;; +esac + +rm -f "$archive_path" +tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 14d38b9db5..67c5c63ac9 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -112,10 +112,22 @@ jobs: - name: Stage Windows binaries shell: bash run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" + release_dir="target/${{ matrix.target }}/release" + output_dir="$release_dir/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" + pdb_name="${binary//-/_}" + pdb_path="$release_dir/${pdb_name}.pdb" + if [[ ! -f "$pdb_path" ]]; then + pdb_path="$release_dir/${binary}.pdb" + fi + if [[ ! -f "$pdb_path" ]]; then + echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 + exit 1 + fi + + cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" + cp "$pdb_path" "$output_dir/${binary}.pdb" done - name: Upload Windows binaries @@ -201,6 +213,23 @@ jobs: account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + - name: Build symbols archive + shell: bash + run: | + bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ + --target "${{ matrix.target }}" \ + --artifact-name "${{ matrix.target }}" \ + --release-dir "target/${{ matrix.target }}/release" \ + --archive-dir "symbols-dist/${{ matrix.target }}" \ + --binaries "${WINDOWS_BINARIES}" + + - name: Upload symbols archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.target }}-symbols + path: codex-rs/symbols-dist/${{ matrix.target }}/* + if-no-files-found: error + - name: Stage artifacts shell: bash run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f95d1216f8..5dbf1b77eb 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -64,6 +64,8 @@ jobs: run: working-directory: codex-rs env: + # macOS release packages archive packed dSYM bundles before stripping. + CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }} # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -163,7 +165,7 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -222,6 +224,10 @@ jobs: exit 1 fi + # Codex embeds this digest at build time and verifies the bundled + # bwrap resource before use. Strip bwrap before hashing so the digest + # covers the exact bytes that the release packages. + strip --strip-debug --strip-unneeded "$bwrap_path" digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" echo "Built bwrap ${bwrap_path} with sha256:${digest}" @@ -235,6 +241,11 @@ jobs: fi build_args=() for binary in ${{ matrix.binaries }}; do + # bwrap was built, finalized, and hashed before this build so + # Codex can embed the digest of the bytes that will be packaged. + if [[ "$binary" == "bwrap" ]]; then + continue + fi build_args+=(--bin "$binary") done cargo build --target "$target" --release --timings "${build_args[@]}" @@ -246,6 +257,32 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn + - name: Build symbols archive and strip binaries + shell: bash + run: | + binaries=() + for binary in ${{ matrix.binaries }}; do + # bwrap is already stripped before hashing. Its symbols are not + # useful enough to justify a separate pre-Codex symbols pass. + if [[ "$binary" == "bwrap" ]]; then + continue + fi + binaries+=("$binary") + done + bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ + --target "${{ matrix.target }}" \ + --artifact-name "${{ matrix.artifact_name }}" \ + --release-dir "target/${{ matrix.target }}/release" \ + --archive-dir "symbols-dist/${{ matrix.artifact_name }}" \ + --binaries "${binaries[*]}" + + - name: Upload symbols archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-symbols + path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/* + if-no-files-found: error + - if: ${{ runner.os == 'macOS' }} name: Stage unsigned macOS artifacts shell: bash diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0e3aba3288..3bb23ba80a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -501,10 +501,11 @@ strip = "symbols" [profile.release] lto = "thin" +debug = "line-tables-only" split-debuginfo = "off" -# Because we bundle some of these executables with the TypeScript CLI, we -# remove everything to make the binary as small as possible. -strip = "symbols" +# Keep release binaries symbolicateable until packaging has archived the +# sidecar symbols and stripped the binaries. +strip = false # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 From b128da272e640371c75913033bdcc96bcea85ec4 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Mon, 8 Jun 2026 10:20:54 -0700 Subject: [PATCH 114/141] fix(app-server): avoid blocking connection cleanup (#26852) ## Why Remote-control app-server sessions can reconnect every 5-7 seconds when the shared transport-event queue fills. The queue's consumer handled `ConnectionClosed` by awaiting all in-flight RPCs for the disconnected connection. A stuck RPC therefore blocked processing of replacement connection and initialize events until remote-control forwarding hit its five-second timeout and reconnected again. Related issue: N/A (internal remote-control incident investigation). ## What Changed - Split fast RPC admission closure from draining: `ConnectionRpcGate::close()` rejects queued and future RPCs, while `shutdown()` continues waiting for RPCs that already started. - Close a disconnected connection's RPC gate before spawning the existing RPC drain and resource cleanup in a tracked background task, so the transport-event consumer remains available without waiting for active RPCs. - Reap completed cleanup tasks during normal operation, drain them during graceful shutdown, and abort them during forced shutdown. - Add regression coverage for closing with an active RPC, rejecting post-close requests without polling them, and preserving the existing shutdown wait behavior. ## Verification `just test -p codex-app-server --lib connection_rpc_gate` passes all 6 tests, including the new close-versus-drain regression coverage. --- codex-rs/app-server/src/connection_cleanup.rs | 49 +++++++++++++++++++ .../app-server/src/connection_rpc_gate.rs | 43 +++++++++++++--- codex-rs/app-server/src/lib.rs | 21 ++++++-- codex-rs/app-server/src/message_processor.rs | 15 +++++- .../app-server/src/request_serialization.rs | 2 +- 5 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 codex-rs/app-server/src/connection_cleanup.rs diff --git a/codex-rs/app-server/src/connection_cleanup.rs b/codex-rs/app-server/src/connection_cleanup.rs new file mode 100644 index 0000000000..529020fe3f --- /dev/null +++ b/codex-rs/app-server/src/connection_cleanup.rs @@ -0,0 +1,49 @@ +use std::future::Future; +use std::future::pending; + +use tokio::task::JoinError; +use tokio::task::JoinSet; +use tracing::warn; + +pub(crate) struct ConnectionCleanupTasks { + tasks: JoinSet<()>, +} + +impl ConnectionCleanupTasks { + pub(crate) fn new() -> Self { + Self { + tasks: JoinSet::new(), + } + } + + pub(crate) fn spawn(&mut self, future: impl Future + Send + 'static) { + self.tasks.spawn(future); + } + + pub(crate) async fn reap_next(&mut self) { + if self.tasks.is_empty() { + pending::<()>().await; + } + if let Some(result) = self.tasks.join_next().await { + log_cleanup_result(result); + } + } + + pub(crate) async fn drain(&mut self) { + while let Some(result) = self.tasks.join_next().await { + log_cleanup_result(result); + } + } + + pub(crate) fn abort(&mut self) { + self.tasks.abort_all(); + } +} + +fn log_cleanup_result(result: Result<(), JoinError>) { + if let Err(err) = result + && !err.is_cancelled() + { + warn!("connection cleanup task failed: {err}"); + } +} diff --git a/codex-rs/app-server/src/connection_rpc_gate.rs b/codex-rs/app-server/src/connection_rpc_gate.rs index 12fed79b36..fb2aedd352 100644 --- a/codex-rs/app-server/src/connection_rpc_gate.rs +++ b/codex-rs/app-server/src/connection_rpc_gate.rs @@ -38,12 +38,14 @@ impl ConnectionRpcGate { drop(token); } + pub(crate) async fn close(&self) { + let mut accepting = self.accepting.lock().await; + *accepting = false; + self.tasks.close(); + } + pub(crate) async fn shutdown(&self) { - { - let mut accepting = self.accepting.lock().await; - *accepting = false; - self.tasks.close(); - } + self.close().await; self.tasks.wait().await; } @@ -90,9 +92,9 @@ mod tests { } #[tokio::test] - async fn run_drops_future_without_polling_after_shutdown() { + async fn run_drops_future_without_polling_after_close() { let gate = ConnectionRpcGate::new(); - gate.shutdown().await; + gate.close().await; let polled = Arc::new(AtomicBool::new(/*v*/ false)); let polled_clone = Arc::clone(&polled); @@ -105,6 +107,33 @@ mod tests { assert!(!gate.is_accepting().await); } + #[tokio::test] + async fn close_returns_while_started_run_remains_active() { + let gate = Arc::new(ConnectionRpcGate::new()); + let (started_tx, started_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let gate_for_run = Arc::clone(&gate); + let run_task = tokio::spawn(async move { + gate_for_run + .run(async move { + started_tx.send(()).expect("receiver should be open"); + let _ = finish_rx.await; + }) + .await; + }); + + started_rx.await.expect("run should start"); + gate.close().await; + assert!(!gate.is_accepting().await); + assert_eq!(gate.inflight_count(), 1); + + finish_tx + .send(()) + .expect("running future should be waiting"); + run_task.await.expect("run task should complete"); + gate.shutdown().await; + } + #[tokio::test] async fn shutdown_waits_for_started_run_to_finish() { let gate = Arc::new(ConnectionRpcGate::new()); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0689a79f00..b6f0b8c859 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -20,6 +20,7 @@ use std::sync::atomic::AtomicBool; use crate::analytics_utils::analytics_events_client_from_config; use crate::config_manager::ConfigManager; +use crate::connection_cleanup::ConnectionCleanupTasks; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -81,6 +82,7 @@ mod command_exec; mod config; mod config_manager; mod config_manager_service; +mod connection_cleanup; mod connection_rpc_gate; mod dynamic_tools; mod error_code; @@ -819,6 +821,7 @@ pub async fn run_main_with_transport_options( let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); let mut connections = HashMap::::new(); + let mut connection_cleanup_tasks = ConnectionCleanupTasks::new(); let mut remote_control_status_rx = remote_control_handle.status_receiver(); let mut remote_control_status = remote_control_status_rx.borrow().clone(); let transport_shutdown_token = transport_shutdown_token.clone(); @@ -906,14 +909,20 @@ pub async fn run_main_with_transport_options( let Some(connection_state) = connections.remove(&connection_id) else { continue; }; - if outbound_control_tx + connection_state.session.rpc_gate.close().await; + let outbound_closed = outbound_control_tx .send(OutboundControlEvent::Closed { connection_id }) .await - .is_err() - { + .is_ok(); + let processor = Arc::clone(&processor); + connection_cleanup_tasks.spawn(async move { + processor + .connection_closed(connection_id, &connection_state.session) + .await; + }); + if !outbound_closed { break; } - processor.connection_closed(connection_id, &connection_state.session).await; if shutdown_when_no_connections && connections.is_empty() { break; } @@ -1010,6 +1019,7 @@ pub async fn run_main_with_transport_options( } } } + _ = connection_cleanup_tasks.reap_next() => {} changed = remote_control_status_rx.changed() => { if changed.is_err() { continue; @@ -1062,8 +1072,11 @@ pub async fn run_main_with_transport_options( .map(|connection_state| connection_state.session.rpc_gate.shutdown()), ) .await; + connection_cleanup_tasks.drain().await; processor.drain_background_tasks().await; processor.shutdown_threads().await; + } else { + connection_cleanup_tasks.abort(); } info!("processor task exited (channel closed)"); } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index b7e0ff258d..65c57bf266 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -91,6 +91,7 @@ use tokio_util::sync::CancellationToken; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); #[derive(Clone)] struct ExternalAuthRefreshBridge { @@ -723,7 +724,19 @@ impl MessageProcessor { connection_id: ConnectionId, session_state: &ConnectionSessionState, ) { - session_state.rpc_gate.shutdown().await; + if timeout( + CONNECTION_RPC_DRAIN_TIMEOUT, + session_state.rpc_gate.shutdown(), + ) + .await + .is_err() + { + tracing::warn!( + ?connection_id, + timeout_seconds = CONNECTION_RPC_DRAIN_TIMEOUT.as_secs(), + "timed out waiting for connection RPCs to drain" + ); + } self.outgoing.connection_closed(connection_id).await; self.fs_processor.connection_closed(connection_id).await; self.command_exec_processor diff --git a/codex-rs/app-server/src/request_serialization.rs b/codex-rs/app-server/src/request_serialization.rs index 0dd167b74d..77ecfc8f56 100644 --- a/codex-rs/app-server/src/request_serialization.rs +++ b/codex-rs/app-server/src/request_serialization.rs @@ -311,7 +311,7 @@ mod tests { let key = RequestSerializationQueueKey::Global("test"); let live_gate = gate(); let closed_gate = gate(); - closed_gate.shutdown().await; + closed_gate.close().await; let (tx, mut rx) = mpsc::unbounded_channel(); let (blocked_tx, blocked_rx) = oneshot::channel::<()>(); From a81531146609a25847ae8d045f30234e6c7eafd7 Mon Sep 17 00:00:00 2001 From: ningyi-oai Date: Mon, 8 Jun 2026 10:49:59 -0700 Subject: [PATCH 115/141] Add HTTP window ID to Responses client metadata (#26923) ## Summary - Keep the existing `x-codex-window-id` HTTP header unchanged. - Also send the same window ID in Responses `client_metadata`, allowing supported backend paths to surface it as `x-client-meta-x-codex-window-id`. - Cover normal HTTP Responses and remote compaction v2 requests without changing window generation or compaction behavior. ## Why In the `2026-06-06T23` production hour, all 28,729 HTTP compaction requests had `window_id` in `x-codex-turn-metadata`, but only 73 retained the direct `x-codex-window-id` header. The request-body `client_metadata` path is already used for installation ID and is preserved through supported Responses API paths. This is additive metadata only. It does not change the direct header, request count, model input, compaction routing, window generation, or user response behavior. Legacy `/v1/responses/compact` is intentionally unchanged. Its current server-side `CompressBody` schema does not accept `client_metadata` and rejects unknown fields, so supporting that path requires a backend schema change before the Codex client can safely send this field. ## Validation - Current head: `219baef3c`, rebased onto `origin/main` at `26d932983`. - The post-rebase diff remains limited to the original five files (`22` insertions, `6` deletions); the legacy experiment remains fully reverted. - `just test -p codex-core responses_stream_includes_subagent_header_on_review`: passed; validates normal HTTP Responses metadata. - `just test -p codex-core remote_compact_v2_reuses_compaction_trigger_for_followups`: passed; validates remote compaction v2. - `just test -p codex-core remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache_key`: passed; validates that legacy compact keeps its accepted payload shape. - `just test -p codex-core remote_manual_compact_api_auth_omits_service_tier_and_reuses_prompt_cache_key`: passed; validates the legacy API-key payload as well. - `just fmt`: passed; an unrelated root `justfile` rewrite produced by the formatter was discarded. - `git diff --check origin/main...HEAD`: passed. The focused server pytest could not start in the local monorepo environment because test setup is missing the `dotenv` module. Server source and tests explicitly show that `CompressBody` omits `client_metadata` and `/v1/responses/compact` returns HTTP 400 for unknown body fields. --- codex-rs/core/src/client.rs | 14 ++++++++++---- codex-rs/core/tests/responses_headers.rs | 4 ++++ codex-rs/core/tests/suite/compact_remote.rs | 4 ++++ ...act_api_auth_prompt_cache_key_request_diff.snap | 3 ++- ...service_tier_prompt_cache_key_request_diff.snap | 3 ++- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index dd7b9f400c..02d231c962 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -802,10 +802,16 @@ impl ModelClient { service_tier, prompt_cache_key, text, - client_metadata: Some(HashMap::from([( - X_CODEX_INSTALLATION_ID_HEADER.to_string(), - self.state.installation_id.clone(), - )])), + client_metadata: Some(HashMap::from([ + ( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + ), + ( + X_CODEX_WINDOW_ID_HEADER.to_string(), + self.current_window_id(), + ), + ])), }; Ok(request) } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 75649fa4bc..1ba93aec10 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -158,6 +158,10 @@ async fn responses_stream_includes_subagent_header_on_review() { request.body_json()["client_metadata"]["x-codex-installation-id"].as_str(), Some(TEST_INSTALLATION_ID) ); + assert_eq!( + request.body_json()["client_metadata"]["x-codex-window-id"].as_str(), + Some(expected_window_id.as_str()) + ); assert_eq!(request.header("x-codex-sandbox"), None); } diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 2200c7b778..12fd570c56 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -883,6 +883,10 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( compact_metadata["window_id"].as_str(), compact_request.header("x-codex-window-id").as_deref() ); + assert_eq!( + compact_request.body_json()["client_metadata"]["x-codex-window-id"].as_str(), + compact_metadata["window_id"].as_str() + ); assert_eq!( compact_metadata["compaction"], json!({ diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap index 750250f86a..89796cb918 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -7,7 +7,8 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s --- Last Normal /responses Request +++ Remote /responses/compact Request - "client_metadata": { -- "x-codex-installation-id": "" +- "x-codex-installation-id": "", +- "x-codex-window-id": ":0" - }, - "include": [ - "reasoning.encrypted_content" diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap index e28ddd30d1..ab6c9d1b37 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -7,7 +7,8 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses --- Last Normal /responses Request +++ Remote /responses/compact Request - "client_metadata": { -- "x-codex-installation-id": "" +- "x-codex-installation-id": "", +- "x-codex-window-id": ":0" - }, - "include": [ - "reasoning.encrypted_content" From f1c18df9aeacb10bb88f56ff6725be252791c705 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 8 Jun 2026 10:52:31 -0700 Subject: [PATCH 116/141] [codex-analytics] report compaction analytics details (#26680) ## Why Compaction analytics adds retained image count and compaction summary output tokens for v1.5 specifically. ## What changed - Add nullable `retained_image_count` and `compaction_summary_tokens` fields to `codex_compaction_event`. - Populate them only for `responses_compaction_v2`: retained images come from the retained v2 compacted history, and summary tokens come from `response.completed.token_usage.output_tokens`. - Leave local and legacy remote compaction events as `null` for these detail fields. ## Verification - `just fmt` - `just fix -p codex-core` - `just test -p codex-core build_v2_compacted_history_counts_retained_input_images` - `git diff --check` --- .../analytics/src/analytics_client_tests.rs | 8 ++ codex-rs/analytics/src/events.rs | 4 + codex-rs/analytics/src/facts.rs | 2 + codex-rs/core/src/compact.rs | 22 ++++- codex-rs/core/src/compact_remote.rs | 34 ++++--- codex-rs/core/src/compact_remote_v2.rs | 93 ++++++++++++++----- 6 files changed, 117 insertions(+), 46 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f4cd18da1e..a3b6dfa949 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1253,6 +1253,8 @@ fn compaction_event_serializes_expected_shape() { error: None, active_context_tokens_before: 120_000, active_context_tokens_after: 18_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 106, duration_ms: Some(6543), @@ -1301,6 +1303,8 @@ fn compaction_event_serializes_expected_shape() { "error": null, "active_context_tokens_before": 120000, "active_context_tokens_after": 18000, + "retained_image_count": null, + "compaction_summary_tokens": null, "started_at": 100, "completed_at": 106, "duration_ms": 6543 @@ -1821,6 +1825,8 @@ async fn compaction_event_ingests_custom_fact() { error: Some("context limit exceeded".to_string()), active_context_tokens_before: 131_000, active_context_tokens_after: 131_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 101, duration_ms: Some(1200), @@ -2743,6 +2749,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { error: None, active_context_tokens_before: 131_000, active_context_tokens_after: 64_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 101, duration_ms: Some(1200), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index f0fcddd27f..019d59776a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -758,6 +758,8 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, + pub(crate) retained_image_count: Option, + pub(crate) compaction_summary_tokens: Option, pub(crate) started_at: u64, pub(crate) completed_at: u64, pub(crate) duration_ms: Option, @@ -970,6 +972,8 @@ pub(crate) fn codex_compaction_event_params( error: input.error, active_context_tokens_before: input.active_context_tokens_before, active_context_tokens_after: input.active_context_tokens_after, + retained_image_count: input.retained_image_count, + compaction_summary_tokens: input.compaction_summary_tokens, started_at: input.started_at, completed_at: input.completed_at, duration_ms: input.duration_ms, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 38af5ed8b8..c9d245ce90 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -434,6 +434,8 @@ pub struct CodexCompactionEvent { pub error: Option, pub active_context_tokens_before: i64, pub active_context_tokens_after: i64, + pub retained_image_count: Option, + pub compaction_summary_tokens: Option, pub started_at: u64, pub completed_at: u64, pub duration_ms: Option, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 18b5838292..e2573d6d6d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -150,7 +150,7 @@ async fn run_compact_task_inner( sess.as_ref(), CompactionStatus::Interrupted, Some(error), - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); @@ -174,7 +174,7 @@ async fn run_compact_task_inner( sess.as_ref(), status, error, - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); @@ -185,7 +185,7 @@ async fn run_compact_task_inner( sess.as_ref(), status, error, - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; result.map(|_| ()) @@ -335,6 +335,13 @@ pub(crate) struct CompactionAnalyticsAttempt { start_instant: Instant, } +#[derive(Clone, Copy, Default)] +pub(crate) struct CompactionAnalyticsDetails { + pub(crate) active_context_tokens_before: Option, + pub(crate) retained_image_count: Option, + pub(crate) compaction_summary_tokens: Option, +} + impl CompactionAnalyticsAttempt { pub(crate) async fn begin( sess: &Session, @@ -363,8 +370,13 @@ impl CompactionAnalyticsAttempt { sess: &Session, status: CompactionStatus, error: Option, - active_context_tokens_before: Option, + details: CompactionAnalyticsDetails, ) { + let CompactionAnalyticsDetails { + active_context_tokens_before, + retained_image_count, + compaction_summary_tokens, + } = details; let active_context_tokens_before = active_context_tokens_before.unwrap_or(self.active_context_tokens_before); let active_context_tokens_after = sess.get_total_token_usage().await; @@ -382,6 +394,8 @@ impl CompactionAnalyticsAttempt { error, active_context_tokens_before, active_context_tokens_after, + retained_image_count, + compaction_summary_tokens, started_at: self.started_at, completed_at: now_unix_seconds(), duration_ms: Some( diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c7730ddfb6..5fca191b84 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; @@ -100,7 +101,10 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompact, phase, ); - let mut active_context_tokens_before = sess.get_total_token_usage().await; + let mut analytics_details = CompactionAnalyticsDetails { + active_context_tokens_before: Some(sess.get_total_token_usage().await), + ..Default::default() + }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -120,7 +124,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), - Some(active_context_tokens_before), + analytics_details, ) .await; return Err(CodexErr::TurnAborted); @@ -131,7 +135,7 @@ async fn run_remote_compact_task_inner( turn_context, initial_context_injection, compaction_metadata, - &mut active_context_tokens_before, + &mut analytics_details, ) .await; let status = compaction_status_from_result(&result); @@ -140,23 +144,13 @@ async fn run_remote_compact_task_inner( let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt - .track( - sess.as_ref(), - status, - error, - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt - .track( - sess.as_ref(), - status, - error.clone(), - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error.clone(), analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); @@ -174,7 +168,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, - active_context_tokens_before: &mut i64, + analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -208,8 +202,12 @@ async fn run_remote_compact_task_inner_impl( .get_total_token_usage_breakdown() .await .estimated_tokens_of_items_added_since_last_successful_api_response; - *active_context_tokens_before = (*active_context_tokens_before) - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); } // This is the history selected for remote compaction, after any output rewriting required to // fit the compact endpoint. The checkpoint below records it separately from the next sampling diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 5f585862dd..1d4eb8ad5a 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -5,6 +5,7 @@ use crate::ResponseStream; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact_remote::build_compact_request_log_data; @@ -112,7 +113,10 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompactionV2, phase, ); - let mut active_context_tokens_before = sess.get_total_token_usage().await; + let mut analytics_details = CompactionAnalyticsDetails { + active_context_tokens_before: Some(sess.get_total_token_usage().await), + ..Default::default() + }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -132,7 +136,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), - Some(active_context_tokens_before), + analytics_details, ) .await; return Err(CodexErr::TurnAborted); @@ -144,7 +148,7 @@ async fn run_remote_compact_task_inner( client_session, initial_context_injection, compaction_metadata, - &mut active_context_tokens_before, + &mut analytics_details, ) .await; let status = compaction_status_from_result(&result); @@ -153,23 +157,13 @@ async fn run_remote_compact_task_inner( let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt - .track( - sess.as_ref(), - status, - error, - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt - .track( - sess.as_ref(), - status, - error.clone(), - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error.clone(), analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); @@ -188,7 +182,7 @@ async fn run_remote_compact_task_inner_impl( client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, - active_context_tokens_before: &mut i64, + analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -221,8 +215,12 @@ async fn run_remote_compact_task_inner_impl( .get_total_token_usage_breakdown() .await .estimated_tokens_of_items_added_since_last_successful_api_response; - *active_context_tokens_before = (*active_context_tokens_before) - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); } let trace_input_history = history.raw_items().to_vec(); @@ -283,9 +281,12 @@ async fn run_remote_compact_task_inner_impl( token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { - *active_context_tokens_before = token_usage.input_tokens; + analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); + analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens); } - let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output); + let (compacted_history, retained_images) = + build_v2_compacted_history(&prompt_input, compaction_output); + analytics_details.retained_image_count = Some(retained_images); let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -447,7 +448,7 @@ async fn collect_compaction_output( fn build_v2_compacted_history( prompt_input: &[ResponseItem], compaction_output: ResponseItem, -) -> Vec { +) -> (Vec, usize) { let retained = prompt_input .iter() .filter(|item| is_retained_for_remote_compaction_v2(item)) @@ -456,8 +457,12 @@ fn build_v2_compacted_history( .collect::>(); let mut retained = truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET); + let retained_image_count = retained + .iter() + .map(retained_input_image_count) + .sum::(); retained.push(compaction_output); - retained + (retained, retained_image_count) } fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { @@ -468,6 +473,17 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { matches!(role.as_str(), "user" | "developer" | "system") } +fn retained_input_image_count(item: &ResponseItem) -> usize { + let ResponseItem::Message { content, .. } = item else { + return 0; + }; + + content + .iter() + .filter(|item| matches!(item, ContentItem::InputImage { .. })) + .count() +} + fn truncate_retained_messages_for_remote_compaction( items: Vec, max_tokens: usize, @@ -617,7 +633,7 @@ mod tests { encrypted_content: "new".to_string(), }; - let history = build_v2_compacted_history(&input, output.clone()); + let (history, _) = build_v2_compacted_history(&input, output.clone()); assert_eq!( history, @@ -644,11 +660,40 @@ mod tests { encrypted_content: "new".to_string(), }; - let history = build_v2_compacted_history(&input, output.clone()); + let (history, _) = build_v2_compacted_history(&input, output.clone()); assert_eq!(history, vec![old, new, output]); } + #[test] + fn build_v2_compacted_history_counts_retained_input_images() { + let input = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "user".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,def".to_string(), + detail: None, + }, + ], + phase: None, + }]; + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), + }; + + let (_, retained_image_count) = build_v2_compacted_history(&input, output); + + assert_eq!(retained_image_count, 2); + } + #[test] fn retained_history_truncation_keeps_newest_messages_first() { let middle = message("user", "middle1234", /*phase*/ None); From 6d8e12ac42508c10bbe1d1769aee7f49d150ba5c Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Mon, 8 Jun 2026 14:16:32 -0400 Subject: [PATCH 117/141] [codex] Speed up external agent session imports (#26637) ## Why Importing large external-agent session histories currently starts a full live Codex thread for every imported session. This initializes unrelated runtime systems and repeats expensive transcript, metadata, hashing, and ledger work. On a 50-session, 238 MiB fixture, the existing path took roughly 70 seconds to complete the import and 77 seconds end to end. ## What changed - Persist imported sessions directly through `ThreadStore` instead of starting full live threads. - Process imports through a bounded five-session pipeline. - Parse, extract, and hash each source file in one pass. - Move blocking source preparation onto the blocking thread pool. - Reuse prepared content hashes and update the import ledger once per batch. - Avoid metadata readback for newly written rollouts. - Preserve imported conversation history and visible thread metadata. - Keep the implementation out of `codex-core` and avoid changes to the public `ThreadStore` trait. ## Performance For the same 50-session, 238 MiB fixture: | Path | Import completion | End to end | | --- | ---: | ---: | | Existing import | 69.61s | 76.62s | | This change | 5.95s | 6.58s | All 50 sessions imported successfully with no warnings or contention signals. ## Validation - `just test -p codex-external-agent-sessions` - `just test -p codex-app-server external_agent_config_import` - Verified imports do not initialize unrelated required MCP servers. - Verified previously imported source versions are skipped and changed sources can be imported again. - Verified imported rollouts remain readable through thread listing and history APIs. --- codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/app-server/src/request_processors.rs | 1 + .../external_agent_config_processor.rs | 143 +--------- .../external_agent_session_import.rs | 260 ++++++++++++++++++ .../tests/suite/v2/external_agent_config.rs | 92 +++++++ .../external-agent-sessions/src/export.rs | 191 +++++++------ .../external-agent-sessions/src/ledger.rs | 67 +++-- .../src/ledger_tests.rs | 85 ++++++ codex-rs/external-agent-sessions/src/lib.rs | 192 +++++-------- .../external-agent-sessions/src/records.rs | 155 +++++++---- 10 files changed, 793 insertions(+), 394 deletions(-) create mode 100644 codex-rs/app-server/src/request_processors/external_agent_session_import.rs create mode 100644 codex-rs/external-agent-sessions/src/ledger_tests.rs diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 65c57bf266..84d3d88dda 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -475,6 +475,7 @@ impl MessageProcessor { let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( outgoing.clone(), Arc::clone(&thread_manager), + Arc::clone(&thread_store), config_manager.clone(), config_processor.clone(), arg0_paths, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 57205798b2..79da49fba4 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -461,6 +461,7 @@ mod command_exec_processor; mod config_processor; mod environment_processor; mod external_agent_config_processor; +mod external_agent_session_import; mod feedback_doctor_report; mod feedback_processor; mod fs_processor; diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 87956182d0..5d4c0594eb 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -26,53 +26,47 @@ use codex_app_server_protocol::MigrationDetails; use codex_app_server_protocol::PluginsMigration; use codex_app_server_protocol::ServerNotification; use codex_arg0::Arg0DispatchPaths; -use codex_core::StartThreadOptions; use codex_core::ThreadManager; -use codex_core::config::ConfigOverrides; use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; -use codex_external_agent_sessions::ImportedExternalAgentSession; -use codex_external_agent_sessions::PendingSessionImport; -use codex_external_agent_sessions::prepare_validated_session_imports; -use codex_external_agent_sessions::record_imported_session; -use codex_protocol::ThreadId; -use codex_protocol::protocol::InitialHistory; -use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadStore; use std::collections::HashSet; use std::path::PathBuf; -use tokio::sync::Semaphore; use super::ConfigRequestProcessor; +use super::external_agent_session_import::ExternalAgentSessionImporter; #[derive(Clone)] pub(crate) struct ExternalAgentConfigRequestProcessor { outgoing: Arc, - codex_home: PathBuf, migration_service: ExternalAgentConfigService, - session_import_permits: Arc, + session_importer: ExternalAgentSessionImporter, thread_manager: Arc, - config_manager: ConfigManager, config_processor: ConfigRequestProcessor, - arg0_paths: Arg0DispatchPaths, } impl ExternalAgentConfigRequestProcessor { pub(crate) fn new( outgoing: Arc, thread_manager: Arc, + thread_store: Arc, config_manager: ConfigManager, config_processor: ConfigRequestProcessor, arg0_paths: Arg0DispatchPaths, codex_home: PathBuf, ) -> Self { + let session_importer = ExternalAgentSessionImporter::new( + codex_home.clone(), + Arc::clone(&thread_manager), + thread_store, + config_manager, + arg0_paths, + ); Self { outgoing, - migration_service: ExternalAgentConfigService::new(codex_home.clone()), - codex_home, - session_import_permits: Arc::new(Semaphore::new(1)), + migration_service: ExternalAgentConfigService::new(codex_home), + session_importer, thread_manager, - config_manager, config_processor, - arg0_paths, } } @@ -207,42 +201,12 @@ impl ExternalAgentConfigRequestProcessor { return Ok(()); } - let session_import_permits = Arc::clone(&self.session_import_permits); - let session_processor = self.clone(); + let session_importer = self.session_importer.clone(); let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); let thread_manager = Arc::clone(&self.thread_manager); tokio::spawn(async move { - let session_imports = async move { - if !pending_session_imports.is_empty() { - let Ok(_session_import_permit) = session_import_permits.acquire_owned().await - else { - return; - }; - let pending_session_imports = session_processor - .prepare_validated_session_imports(pending_session_imports); - for pending_session_import in pending_session_imports { - match session_processor - .import_external_agent_session(pending_session_import.session) - .await - { - Ok(imported_thread_id) => { - session_processor.record_imported_session( - &pending_session_import.source_path, - imported_thread_id, - ); - } - Err(error) => { - tracing::warn!( - error = %error.message, - path = %pending_session_import.source_path.display(), - "external agent session import failed" - ); - } - } - } - } - }; + let session_imports = session_importer.import_sessions(pending_session_imports); let plugin_imports = async move { for pending_plugin_import in pending_plugin_imports { match plugin_processor @@ -274,65 +238,6 @@ impl ExternalAgentConfigRequestProcessor { Ok(()) } - async fn import_external_agent_session( - &self, - session: ImportedExternalAgentSession, - ) -> Result { - let ImportedExternalAgentSession { - cwd, - title, - rollout_items, - } = session; - let config = self - .config_manager - .load_with_overrides( - /*request_overrides*/ None, - ConfigOverrides { - cwd: Some(PathBuf::from(cwd.to_string_lossy().into_owned())), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }, - ) - .await - .map_err(|err| { - internal_error(format!("failed to load imported session config: {err}")) - })?; - let environments = self - .thread_manager - .default_environment_selections(&config.cwd); - let imported_thread = self - .thread_manager - .start_thread_with_options(StartThreadOptions { - config, - initial_history: InitialHistory::Forked(rollout_items), - session_source: None, - thread_source: None, - dynamic_tools: Vec::new(), - metrics_service_name: None, - parent_trace: None, - environments, - }) - .await - .map_err(|err| internal_error(format!("failed to import session: {err}")))?; - if let Some(title) = title - && let Some(name) = codex_core::util::normalize_thread_name(&title) - { - imported_thread - .thread - .update_thread_metadata( - ThreadMetadataPatch { - name: Some(Some(name)), - ..Default::default() - }, - /*include_archived*/ false, - ) - .await - .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; - } - Ok(imported_thread.thread_id) - } - fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, @@ -371,24 +276,6 @@ impl ExternalAgentConfigRequestProcessor { Ok(selected_sessions) } - fn prepare_validated_session_imports( - &self, - sessions: Vec, - ) -> Vec { - prepare_validated_session_imports(&self.codex_home, sessions) - } - - fn record_imported_session(&self, source_path: &std::path::Path, imported_thread_id: ThreadId) { - if let Err(err) = record_imported_session(&self.codex_home, source_path, imported_thread_id) - { - tracing::warn!( - error = %err, - path = %source_path.display(), - "external agent session import ledger update failed" - ); - } - } - async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs new file mode 100644 index 0000000000..163210f164 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -0,0 +1,260 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::Utc; +use codex_arg0::Arg0DispatchPaths; +use codex_core::ThreadManager; +use codex_core::config::ConfigOverrides; +use codex_external_agent_sessions::CompletedExternalAgentSessionImport; +use codex_external_agent_sessions::ExternalAgentSessionMigration; +use codex_external_agent_sessions::ImportedExternalAgentSession; +use codex_external_agent_sessions::PendingSessionImport; +use codex_external_agent_sessions::prepare_validated_session_import; +use codex_external_agent_sessions::record_completed_session_imports; +use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_rollout::is_persisted_rollout_item; +use codex_thread_store::AppendThreadItemsParams; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; +use codex_thread_store::UpdateThreadMetadataParams; +use futures::StreamExt; +use tokio::sync::Semaphore; + +use crate::config_manager::ConfigManager; + +const SESSION_IMPORT_CONCURRENCY: usize = 5; + +#[derive(Clone)] +pub(super) struct ExternalAgentSessionImporter { + codex_home: PathBuf, + permits: Arc, + thread_manager: Arc, + thread_store: Arc, + config_manager: ConfigManager, + arg0_paths: Arg0DispatchPaths, +} + +impl ExternalAgentSessionImporter { + pub(super) fn new( + codex_home: PathBuf, + thread_manager: Arc, + thread_store: Arc, + config_manager: ConfigManager, + arg0_paths: Arg0DispatchPaths, + ) -> Self { + Self { + codex_home, + permits: Arc::new(Semaphore::new(1)), + thread_manager, + thread_store, + config_manager, + arg0_paths, + } + } + + pub(super) async fn import_sessions(&self, sessions: Vec) { + if sessions.is_empty() { + return; + } + let Ok(_permit) = self.permits.acquire().await else { + return; + }; + let import_results = futures::stream::iter(sessions) + .map(|session| { + let importer = self.clone(); + async move { importer.import_requested_session(session).await } + }) + .buffer_unordered(SESSION_IMPORT_CONCURRENCY); + futures::pin_mut!(import_results); + + let mut completed_imports = Vec::new(); + while let Some(result) = import_results.next().await { + match result { + Ok(Some(completed_import)) => completed_imports.push(completed_import), + Ok(None) => {} + Err(failure) => { + tracing::warn!( + error = %failure.message, + path = %failure.source_path.display(), + "external agent session import failed" + ); + } + } + } + if let Err(err) = record_completed_session_imports(&self.codex_home, completed_imports) { + tracing::warn!( + error = %err, + "external agent session import ledger update failed" + ); + } + } + + async fn import_requested_session( + &self, + session: ExternalAgentSessionMigration, + ) -> Result, SessionImportFailure> { + let source_path = session.path.clone(); + let Some(pending_import) = + self.prepare_session_import(session) + .await + .map_err(|message| SessionImportFailure { + source_path: source_path.clone(), + message, + })? + else { + return Ok(None); + }; + let imported_thread_id = + self.persist_session(pending_import.session) + .await + .map_err(|message| SessionImportFailure { + source_path: pending_import.source_path.clone(), + message, + })?; + Ok(Some(CompletedExternalAgentSessionImport { + source_path: pending_import.source_path, + source_content_sha256: pending_import.source_content_sha256, + imported_thread_id, + })) + } + + async fn prepare_session_import( + &self, + session: ExternalAgentSessionMigration, + ) -> Result, String> { + let codex_home = self.codex_home.clone(); + tokio::task::spawn_blocking(move || prepare_validated_session_import(&codex_home, session)) + .await + .map_err(|err| format!("external agent session preparation task failed: {err}"))? + .map_err(|err| format!("failed to prepare external agent session: {err}")) + } + + async fn persist_session( + &self, + session: ImportedExternalAgentSession, + ) -> Result { + let ImportedExternalAgentSession { + cwd, + title, + first_user_message, + mut rollout_items, + } = session; + let config = self + .config_manager + .load_with_overrides( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(cwd), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }, + ) + .await + .map_err(|err| format!("failed to load imported session config: {err}"))?; + let models_manager = self.thread_manager.get_models_manager(); + let model = models_manager + .get_default_model(&config.model, RefreshStrategy::Offline) + .await; + let model_info = models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; + let thread_id = ThreadId::new(); + let source = self.thread_manager.session_source(); + let cwd = config.cwd.to_path_buf(); + let model_provider = config.model_provider_id.clone(); + let memory_mode = if config.memories.generate_memories { + ThreadMemoryMode::Enabled + } else { + ThreadMemoryMode::Disabled + }; + let now = Utc::now(); + let create_params = CreateThreadParams { + thread_id, + forked_from_id: None, + parent_thread_id: None, + source: source.clone(), + thread_source: None, + base_instructions: BaseInstructions { + text: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + }, + dynamic_tools: Vec::new(), + multi_agent_version: Some(MultiAgentVersion::V1), + metadata: ThreadPersistenceMetadata { + cwd: Some(cwd.clone()), + model_provider: model_provider.clone(), + memory_mode, + }, + }; + rollout_items.retain(is_persisted_rollout_item); + let title = title + .as_deref() + .and_then(codex_core::util::normalize_thread_name); + let metadata = ThreadMetadataPatch { + title, + preview: first_user_message.clone(), + model_provider: Some(model_provider), + created_at: Some(now), + updated_at: Some(now), + source: Some(source.clone()), + thread_source: Some(None), + agent_nickname: Some(source.get_nickname()), + agent_role: Some(source.get_agent_role()), + agent_path: Some(source.get_agent_path().map(Into::into)), + cwd: Some(cwd), + cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), + first_user_message, + memory_mode: Some(memory_mode), + ..Default::default() + }; + + self.thread_store + .create_thread(create_params) + .await + .map_err(|err| format!("failed to import session: {err}"))?; + if !rollout_items.is_empty() + && let Err(err) = self + .thread_store + .append_items(AppendThreadItemsParams { + thread_id, + items: rollout_items, + }) + .await + { + let _ = self.thread_store.discard_thread(thread_id).await; + return Err(format!("failed to import session: {err}")); + } + + self.thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: metadata, + include_archived: false, + }) + .await + .map_err(|err| format!("failed to update imported session: {err}"))?; + self.thread_store + .persist_thread(thread_id) + .await + .map_err(|err| format!("failed to persist imported session: {err}"))?; + self.thread_store + .shutdown_thread(thread_id) + .await + .map_err(|err| format!("failed to shutdown imported session: {err}"))?; + Ok(thread_id) + } +} + +struct SessionImportFailure { + source_path: PathBuf, + message: String, +} diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index fdb996c2f4..991e12dc78 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -435,6 +435,98 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { Ok(()) } +#[tokio::test] +async fn external_agent_config_import_does_not_initialize_required_mcp() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let mut config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + config.push_str( + r#" +[mcp_servers.required_broken] +command = "this-command-does-not-exist" +required = true +"#, + ); + std::fs::write(codex_home.path().join("config.toml"), config)?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": session_path, + "cwd": project_root, + "title": "first request" + }] + } + }] + })), + ) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_import_accepts_detected_session_payload_after_restart() -> Result<()> { diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index e09805d4d2..05a3004ee9 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -1,10 +1,7 @@ use crate::ConversationMessage; use crate::ImportedExternalAgentSession; use crate::MessageRole; -use crate::records::conversation_messages; -use crate::records::project_root_from_records; -use crate::records::read_records; -use crate::records::source_title_from_records; +use crate::records::read_session_import; use crate::summarize_for_label; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -23,44 +20,55 @@ use std::path::Path; const EXTERNAL_SESSION_IMPORTED_MARKER: &str = ""; -pub fn load_session_for_import(path: &Path) -> io::Result> { - let records = read_records(path)?; - let Some(cwd) = project_root_from_records(&records) else { +#[cfg(test)] +fn load_session_for_import(path: &Path) -> io::Result> { + Ok( + load_session_for_import_with_content_sha256(path)? + .map(|(session, _content_sha256)| session), + ) +} + +pub(crate) fn load_session_for_import_with_content_sha256( + path: &Path, +) -> io::Result> { + let parsed = read_session_import(path)?; + let Some(cwd) = parsed.cwd else { return Ok(None); }; - let messages = conversation_messages(&records); - let rollout_items = rollout_items_from_messages(&messages); + let messages = parsed.messages; + let first_user_message = messages + .iter() + .find(|message| message.role == MessageRole::User) + .map(|message| summarize_for_label(&message.text)); + let title = parsed.source_title.or_else(|| first_user_message.clone()); + let rollout_items = rollout_items_from_messages(messages); if rollout_items.is_empty() { return Ok(None); } - let title = source_title_from_records(&records).or_else(|| { - messages - .iter() - .find(|message| message.role == MessageRole::User) - .map(|message| summarize_for_label(&message.text)) - }); - Ok(Some(ImportedExternalAgentSession { - cwd, - title, - rollout_items, - })) + Ok(Some(( + ImportedExternalAgentSession { + cwd, + title, + first_user_message, + rollout_items, + }, + parsed.content_sha256, + ))) } -fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec { +fn rollout_items_from_messages(messages: Vec) -> Vec { let mut items = Vec::new(); - let mut response_items = Vec::new(); - let mut current_turn: Option<(String, Option)> = None; + let mut current_turn = None; + let mut response_item_bytes = 0i64; + let mut last_model_visible_tokens = 0i64; let mut user_turn_count = 0usize; + let completed_at = messages.last().and_then(|message| message.timestamp); for message in messages { match message.role { MessageRole::User => { - if let Some((turn_id, last_agent_message)) = current_turn.take() { - items.push(turn_complete_item( - turn_id, - last_agent_message, - /*completed_at*/ None, - )); + if let Some(turn_id) = current_turn.take() { + items.push(turn_complete_item(turn_id, /*completed_at*/ None)); } user_turn_count += 1; let turn_id = format!("external-import-turn-{user_turn_count}"); @@ -73,28 +81,24 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec { - let Some((_, last_agent_message)) = current_turn.as_mut() else { + if current_turn.is_none() { continue; - }; - let response_item = response_item(message); - response_items.push(response_item.clone()); - items.push(RolloutItem::ResponseItem(response_item)); + } + response_item_bytes = + response_item_bytes.saturating_add(message_byte_count(&message)); + last_model_visible_tokens = approx_tokens_from_byte_count_i64(response_item_bytes); items.push(RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { message: message.text.clone(), @@ -102,20 +106,15 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec RolloutItem { })) } -fn response_item(message: &ConversationMessage) -> ResponseItem { +fn response_item(message: ConversationMessage) -> ResponseItem { let content = match message.role { - MessageRole::Assistant => ContentItem::OutputText { - text: message.text.clone(), - }, - MessageRole::User => ContentItem::InputText { - text: message.text.clone(), - }, + MessageRole::Assistant => ContentItem::OutputText { text: message.text }, + MessageRole::User => ContentItem::InputText { text: message.text }, }; ResponseItem::Message { id: None, @@ -149,13 +144,11 @@ fn response_item(message: &ConversationMessage) -> ResponseItem { } } -fn token_count_item(response_items: &[ResponseItem]) -> RolloutItem { - let last_model_generated = response_items.iter().rposition( - |item| matches!(item, ResponseItem::Message { role, .. } if role == "assistant"), - ); - let last_model_visible_tokens = last_model_generated - .map(|index| estimate_response_items_token_count(&response_items[..=index])) - .unwrap_or_default(); +fn message_byte_count(message: &ConversationMessage) -> i64 { + i64::try_from(message.text.len()).unwrap_or(i64::MAX) +} + +fn token_count_item(last_model_visible_tokens: i64) -> RolloutItem { let usage = TokenUsage { total_tokens: last_model_visible_tokens, ..TokenUsage::default() @@ -170,26 +163,10 @@ fn token_count_item(response_items: &[ResponseItem]) -> RolloutItem { })) } -fn estimate_response_items_token_count(response_items: &[ResponseItem]) -> i64 { - response_items - .iter() - .map(|item| { - serde_json::to_string(item) - .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) - .map(approx_tokens_from_byte_count_i64) - .unwrap_or_default() - }) - .fold(0i64, i64::saturating_add) -} - -fn turn_complete_item( - turn_id: String, - last_agent_message: Option, - completed_at: Option, -) -> RolloutItem { +fn turn_complete_item(turn_id: String, completed_at: Option) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, - last_agent_message, + last_agent_message: None, completed_at, duration_ms: None, time_to_first_token_ms: None, @@ -241,7 +218,7 @@ mod tests { } #[test] - fn adds_import_marker_without_replacing_last_agent_message() { + fn adds_import_marker_without_copying_last_agent_message() { let root = TempDir::new().expect("tempdir"); let project_root = root.path().join("repo"); std::fs::create_dir_all(&project_root).expect("project root"); @@ -280,10 +257,54 @@ mod tests { }); assert_eq!( last_turn_complete.and_then(|event| event.last_agent_message.as_deref()), - Some("first answer") + None ); } + #[test] + fn stores_imported_messages_as_response_items_and_visible_events() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let request = "r".repeat(1_000); + let answer = "a".repeat(1_000); + std::fs::write( + &path, + jsonl(&[ + record("user", &request, &project_root), + record("assistant", &answer, &project_root), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let response_message_count = imported + .rollout_items + .iter() + .filter(|item| { + matches!( + item, + RolloutItem::ResponseItem(ResponseItem::Message { .. }) + ) + }) + .count(); + let visible_message_event_count = imported + .rollout_items + .iter() + .filter(|item| match item { + RolloutItem::EventMsg(EventMsg::UserMessage(event)) => event.message == request, + RolloutItem::EventMsg(EventMsg::AgentMessage(event)) => event.message == answer, + _ => false, + }) + .count(); + + assert_eq!(response_message_count, 2); + assert_eq!(visible_message_event_count, 2); + } + #[test] fn loads_custom_title_for_imported_session() { let root = TempDir::new().expect("tempdir"); diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 45ff97bcf0..9a3b2042b8 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -30,6 +30,13 @@ struct ImportedExternalAgentSessionRecord { source_modified_at: Option, } +#[derive(Debug, PartialEq, Eq)] +pub struct CompletedExternalAgentSessionImport { + pub source_path: PathBuf, + pub source_content_sha256: String, + pub imported_thread_id: ThreadId, +} + #[derive(Debug, Clone, Copy)] pub(super) struct ImportedSourceState { pub source_modified_at: Option, @@ -43,29 +50,50 @@ pub fn has_current_session_been_imported( load_import_ledger(codex_home)?.contains_current_source(source_path) } -pub fn record_imported_session( +#[cfg(test)] +pub(crate) fn record_imported_session( codex_home: &Path, source_path: &Path, imported_thread_id: ThreadId, ) -> io::Result<()> { - let mut ledger = load_import_ledger(codex_home)?; let source_path = canonical_source_path(source_path)?; - let content_sha256 = session_content_sha256(&source_path)?; - let source_modified_at = session_modified_at(&source_path)?; - if let Some(index) = ledger.records.iter().rposition(|record| { - record.source_path == source_path && record.content_sha256 == content_sha256 - }) { - let mut record = ledger.records.remove(index); - record.imported_thread_id = imported_thread_id; - record.imported_at = now_unix_seconds(); - record.source_modified_at = source_modified_at; - ledger.records.push(record); - } else { - ledger.records.push(ImportedExternalAgentSessionRecord { + record_completed_session_imports( + codex_home, + vec![CompletedExternalAgentSessionImport { + source_content_sha256: session_content_sha256(&source_path)?, source_path, - content_sha256, imported_thread_id, - imported_at: now_unix_seconds(), + }], + ) +} + +pub fn record_completed_session_imports( + codex_home: &Path, + imports: Vec, +) -> io::Result<()> { + if imports.is_empty() { + return Ok(()); + } + let mut ledger = load_import_ledger(codex_home)?; + let imported_at = now_unix_seconds(); + for import in imports { + let source_modified_at = session_modified_at(&import.source_path).ok().flatten(); + if let Some(index) = ledger.records.iter().rposition(|record| { + record.source_path == import.source_path + && record.content_sha256 == import.source_content_sha256 + }) { + let mut record = ledger.records.remove(index); + record.imported_thread_id = import.imported_thread_id; + record.imported_at = imported_at; + record.source_modified_at = source_modified_at.or(record.source_modified_at); + ledger.records.push(record); + continue; + } + ledger.records.push(ImportedExternalAgentSessionRecord { + source_path: import.source_path, + content_sha256: import.source_content_sha256, + imported_thread_id: import.imported_thread_id, + imported_at, source_modified_at, }); } @@ -88,6 +116,9 @@ impl ImportedExternalAgentSessionLedger { } pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { + if self.records.is_empty() { + return Ok(false); + } let source_path = canonical_source_path(source_path)?; if !self .records @@ -188,3 +219,7 @@ fn session_modified_at(path: &Path) -> io::Result> { .ok() .and_then(|duration| i64::try_from(duration.as_nanos()).ok())) } + +#[cfg(test)] +#[path = "ledger_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-sessions/src/ledger_tests.rs b/codex-rs/external-agent-sessions/src/ledger_tests.rs new file mode 100644 index 0000000000..f4b9dd6f7c --- /dev/null +++ b/codex-rs/external-agent-sessions/src/ledger_tests.rs @@ -0,0 +1,85 @@ +use super::CompletedExternalAgentSessionImport; +use super::ImportedExternalAgentSessionLedger; +use super::record_completed_session_imports; +use codex_protocol::ThreadId; +use sha2::Digest; +use sha2::Sha256; +use tempfile::TempDir; + +#[test] +fn empty_ledger_does_not_read_source() { + let root = TempDir::new().expect("tempdir"); + let missing_source = root.path().join("missing-session.jsonl"); + + assert!( + !ImportedExternalAgentSessionLedger::default() + .contains_current_source(&missing_source) + .expect("empty ledger cannot contain sources") + ); +} + +#[test] +fn completed_imports_do_not_read_source_files() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let source_path = root.path().join("session.jsonl"); + let contents = b"session contents"; + std::fs::write(&source_path, contents).expect("source"); + let source_path = std::fs::canonicalize(&source_path).expect("canonical source"); + std::fs::remove_file(&source_path).expect("remove source"); + let imported_thread_id = ThreadId::new(); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: source_path.clone(), + source_content_sha256: format!("{:x}", Sha256::digest(contents)), + imported_thread_id, + }], + ) + .expect("record completed imports"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].source_path, source_path); + assert_eq!(ledger.records[0].imported_thread_id, imported_thread_id); + assert_eq!(ledger.records[0].source_modified_at, None); +} + +#[test] +fn completed_import_refreshes_existing_record_metadata() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let source_path = root.path().join("session.jsonl"); + let contents = b"session contents"; + std::fs::write(&source_path, contents).expect("source"); + let source_path = std::fs::canonicalize(source_path).expect("canonical source"); + let content_sha256 = format!("{:x}", Sha256::digest(contents)); + let first_thread_id = ThreadId::new(); + let second_thread_id = ThreadId::new(); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: source_path.clone(), + source_content_sha256: content_sha256.clone(), + imported_thread_id: first_thread_id, + }], + ) + .expect("record first import"); + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: source_path.clone(), + source_content_sha256: content_sha256, + imported_thread_id: second_thread_id, + }], + ) + .expect("record replacement import"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].source_path, source_path); + assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); + assert!(ledger.records[0].source_modified_at.is_some()); +} diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index fe9699f0c1..0b7a4eb2ba 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -6,15 +6,15 @@ mod ledger; mod records; use codex_protocol::protocol::RolloutItem; -use std::collections::HashSet; use std::io; use std::path::Path; use std::path::PathBuf; pub use detect::detect_recent_sessions; -pub use export::load_session_for_import; +use export::load_session_for_import_with_content_sha256; +pub use ledger::CompletedExternalAgentSessionImport; pub use ledger::has_current_session_been_imported; -pub use ledger::record_imported_session; +pub use ledger::record_completed_session_imports; pub use records::SessionSummary; pub use records::summarize_session; @@ -31,105 +31,51 @@ pub struct ExternalAgentSessionMigration { pub struct ImportedExternalAgentSession { pub cwd: PathBuf, pub title: Option, + pub first_user_message: Option, pub rollout_items: Vec, } #[derive(Debug, Clone)] pub struct PendingSessionImport { pub source_path: PathBuf, + pub source_content_sha256: String, pub session: ImportedExternalAgentSession, } -#[derive(Debug)] -pub enum PrepareSessionImportsError { - SessionNotDetected(PathBuf), -} - -impl std::fmt::Display for PrepareSessionImportsError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PrepareSessionImportsError::SessionNotDetected(path) => { - write!( - formatter, - "external agent session was not detected for import: {}", - path.display() - ) - } - } - } -} - -impl std::error::Error for PrepareSessionImportsError {} - -pub fn prepare_pending_session_imports( - codex_home: &Path, - requested_sessions: Vec, - detected_sessions: Vec, -) -> Result, PrepareSessionImportsError> { - let detected_session_paths = detected_sessions - .into_iter() - .map(|session| session.path) - .collect::>(); - let mut pending_session_imports = Vec::new(); - for session in requested_sessions { - let has_been_imported = match has_current_session_been_imported(codex_home, &session.path) { - Ok(has_been_imported) => has_been_imported, - Err(_) => continue, - }; - if !detected_session_paths.contains(&session.path) && !has_been_imported { - return Err(PrepareSessionImportsError::SessionNotDetected(session.path)); - } - if has_been_imported { - continue; - } - let imported_session = match load_importable_session(&session.path) { - Ok(Some(imported_session)) => imported_session, - Ok(None) | Err(_) => continue, - }; - pending_session_imports.push(PendingSessionImport { - source_path: session.path, - session: imported_session, - }); - } - Ok(pending_session_imports) -} - -pub fn prepare_validated_session_imports( - codex_home: &Path, - requested_sessions: Vec, -) -> Vec { - requested_sessions - .into_iter() - .filter_map(|session| pending_session_import(codex_home, session)) - .collect() -} - -fn pending_session_import( +pub fn prepare_validated_session_import( codex_home: &Path, session: ExternalAgentSessionMigration, -) -> Option { - let has_been_imported = match has_current_session_been_imported(codex_home, &session.path) { - Ok(has_been_imported) => has_been_imported, - Err(_) => return None, - }; +) -> io::Result> { + let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?; if has_been_imported { - return None; + return Ok(None); } - let imported_session = match load_importable_session(&session.path) { - Ok(Some(imported_session)) => imported_session, - Ok(None) | Err(_) => return None, - }; - Some(PendingSessionImport { - source_path: session.path, - session: imported_session, - }) -} - -fn load_importable_session(path: &Path) -> io::Result> { - let Some(imported_session) = load_session_for_import(path)? else { + let Some((source_path, imported_session, source_content_sha256)) = + load_importable_session(&session.path)? + else { return Ok(None); }; - Ok(imported_session.cwd.is_dir().then_some(imported_session)) + Ok(Some(PendingSessionImport { + source_path, + source_content_sha256, + session: imported_session, + })) +} + +fn load_importable_session( + path: &Path, +) -> io::Result> { + let source_path = std::fs::canonicalize(path)?; + let Some((imported_session, source_content_sha256)) = + load_session_for_import_with_content_sha256(&source_path)? + else { + return Ok(None); + }; + Ok(imported_session.cwd.is_dir().then_some(( + source_path, + imported_session, + source_content_sha256, + ))) } #[derive(Debug, Clone)] @@ -172,45 +118,59 @@ fn now_unix_seconds() -> i64 { mod tests { use super::*; use codex_protocol::ThreadId; + use sha2::Digest; + use sha2::Sha256; use tempfile::TempDir; - #[test] - fn rejects_session_that_was_not_detected() { - let root = TempDir::new().expect("tempdir"); - let codex_home = root.path().join("codex-home"); - let source_path = root.path().join("session.jsonl"); - std::fs::write(&source_path, "{}\n").expect("session"); - - let err = prepare_pending_session_imports( - &codex_home, - vec![session_migration(&source_path)], - Vec::new(), - ) - .expect_err("undetected session should be rejected"); - - match err { - PrepareSessionImportsError::SessionNotDetected(path) => { - assert_eq!(path, source_path); - } - } - } - #[test] fn skips_session_that_was_already_imported() { let root = TempDir::new().expect("tempdir"); let codex_home = root.path().join("codex-home"); let source_path = root.path().join("session.jsonl"); std::fs::write(&source_path, "{}\n").expect("session"); - record_imported_session(&codex_home, &source_path, ThreadId::new()).expect("record import"); + ledger::record_imported_session(&codex_home, &source_path, ThreadId::new()) + .expect("record import"); - let pending = prepare_pending_session_imports( - &codex_home, - vec![session_migration(&source_path)], - Vec::new(), - ) - .expect("already imported session should be skipped"); + let pending = + prepare_validated_session_import(&codex_home, session_migration(&source_path)) + .expect("already imported session should be skipped"); - assert!(pending.is_empty()); + assert!(pending.is_none()); + } + + #[test] + fn reports_session_preparation_errors() { + let root = TempDir::new().expect("tempdir"); + let source_path = root.path().join("missing-session.jsonl"); + + let err = prepare_validated_session_import(root.path(), session_migration(&source_path)) + .expect_err("missing session should fail preparation"); + + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn prepares_one_validated_session_import_with_content_hash() { + let root = TempDir::new().expect("tempdir"); + let source_path = root.path().join("session.jsonl"); + let contents = serde_json::json!({ + "type": "user", + "cwd": root.path(), + "timestamp": "2026-06-03T12:00:00Z", + "message": { "content": "first request" }, + }) + .to_string(); + std::fs::write(&source_path, &contents).expect("session"); + + let pending = + prepare_validated_session_import(root.path(), session_migration(&source_path)) + .expect("prepare session") + .expect("pending import"); + + assert_eq!( + pending.source_content_sha256, + format!("{:x}", Sha256::digest(contents)) + ); } fn session_migration(path: &Path) -> ExternalAgentSessionMigration { diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 52f0535452..00307fa1d5 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -4,6 +4,8 @@ use crate::MessageRole; use crate::summarize_for_label; use crate::truncate; use serde_json::Value as JsonValue; +use sha2::Digest; +use sha2::Sha256; use std::fs::File; use std::io; use std::io::BufRead; @@ -21,6 +23,13 @@ pub struct SessionSummary { pub migration: ExternalAgentSessionMigration, } +pub(super) struct ParsedSessionImport { + pub cwd: Option, + pub source_title: Option, + pub messages: Vec, + pub content_sha256: String, +} + pub fn summarize_session(path: &Path) -> io::Result> { let file = File::open(path)?; let reader = BufReader::new(file); @@ -37,7 +46,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { if trimmed.is_empty() { continue; } - let Ok(record) = serde_json::from_str::(trimmed) else { + let Ok(mut record) = serde_json::from_str::(trimmed) else { continue; }; if cwd.is_none() { @@ -52,7 +61,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { if let Some(title) = ai_title_from_record(&record) { ai_title = Some(title.to_string()); } - let Some(message) = conversation_message_from_record(&record) else { + let Some(message) = conversation_message_from_owned_record(&mut record) else { continue; }; saw_message = true; @@ -84,54 +93,50 @@ pub fn summarize_session(path: &Path) -> io::Result> { })) } -pub(super) fn source_title_from_records(records: &[JsonValue]) -> Option { - latest_title_from_records(records, custom_title_from_record) - .or_else(|| latest_title_from_records(records, ai_title_from_record)) -} - -pub(super) fn read_records(path: &Path) -> io::Result> { +pub(super) fn read_session_import(path: &Path) -> io::Result { let file = File::open(path)?; - let reader = BufReader::new(file); - let mut records = Vec::new(); - for line in reader.lines() { - let line = line?; + let mut reader = BufReader::new(file); + let mut cwd = None; + let mut custom_title = None; + let mut ai_title = None; + let mut messages = Vec::new(); + let mut line = String::new(); + let mut hasher = Sha256::new(); + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + hasher.update(line.as_bytes()); let trimmed = line.trim(); if trimmed.is_empty() { continue; } - let Ok(value) = serde_json::from_str::(trimmed) else { + let Ok(mut record) = serde_json::from_str::(trimmed) else { continue; }; - if value.is_object() { - records.push(value); + if cwd.is_none() { + cwd = record + .get("cwd") + .and_then(JsonValue::as_str) + .map(PathBuf::from); + } + if let Some(title) = custom_title_from_record(&record) { + custom_title = Some(title.to_string()); + } + if let Some(title) = ai_title_from_record(&record) { + ai_title = Some(title.to_string()); + } + if let Some(message) = conversation_message_from_owned_record(&mut record) { + messages.push(message); } } - Ok(records) -} - -pub(super) fn project_root_from_records(records: &[JsonValue]) -> Option { - records - .iter() - .find_map(|record| record.get("cwd").and_then(JsonValue::as_str)) - .map(PathBuf::from) -} - -pub(super) fn conversation_messages(records: &[JsonValue]) -> Vec { - records - .iter() - .filter_map(conversation_message_from_record) - .collect() -} - -fn latest_title_from_records<'a>( - records: &'a [JsonValue], - title_from_record: impl Fn(&'a JsonValue) -> Option<&'a str>, -) -> Option { - records - .iter() - .filter_map(title_from_record) - .next_back() - .map(ToOwned::to_owned) + Ok(ParsedSessionImport { + cwd, + source_title: custom_title.or(ai_title), + messages, + content_sha256: format!("{:x}", hasher.finalize()), + }) } fn custom_title_from_record(record: &JsonValue) -> Option<&str> { @@ -150,7 +155,7 @@ fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str) .filter(|title| !title.is_empty()) } -fn conversation_message_from_record(record: &JsonValue) -> Option { +fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option { let record_type = record.get("type")?.as_str()?; if record_type != "assistant" && record_type != "user" { return None; @@ -161,18 +166,30 @@ fn conversation_message_from_record(record: &JsonValue) -> Option { + if text.trim().is_empty() { + return None; + } + ExtractedMessage { + text, + only_tool_result: false, + } + } + content => extract_message_text(&content)?, + }; Some(ConversationMessage { - role, + role: if is_assistant || extracted.only_tool_result { + MessageRole::Assistant + } else { + MessageRole::User + }, text: extracted.text, timestamp, }) @@ -324,6 +341,46 @@ fn parse_timestamp(timestamp: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; + + #[test] + fn reads_session_import_in_one_pass() { + let root = TempDir::new().expect("tempdir"); + let path = root.path().join("session.jsonl"); + let contents = [ + serde_json::json!({ + "type": "user", + "cwd": root.path(), + "timestamp": "2026-06-03T12:00:00Z", + "message": { "content": "first request" }, + }) + .to_string(), + "not json".to_string(), + serde_json::json!({ + "type": "ai-title", + "aiTitle": "generated title", + }) + .to_string(), + serde_json::json!({ + "type": "custom-title", + "customTitle": "custom title", + }) + .to_string(), + ] + .join("\n"); + std::fs::write(&path, &contents).expect("session"); + + let parsed = read_session_import(&path).expect("parse session"); + + assert_eq!(parsed.cwd.as_deref(), Some(root.path())); + assert_eq!(parsed.source_title.as_deref(), Some("custom title")); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].text, "first request"); + assert_eq!( + parsed.content_sha256, + format!("{:x}", Sha256::digest(contents)) + ); + } #[test] fn converts_tool_use_blocks_to_bounded_external_agent_tags() { From 0aa9931aeadd9cbb1d6d02f854d1402f8db2bec8 Mon Sep 17 00:00:00 2001 From: mpc-oai Date: Mon, 8 Jun 2026 13:37:55 -0500 Subject: [PATCH 118/141] [plugins] Expose marketplace source in marketplace list JSON (#27009) ## Summary - Follow-up to #26417 and #26631 - Add `marketplaceSource` to `codex plugin marketplace list --json` entries for configured marketplaces - Reuse the existing `marketplaceSource` shape from `codex plugin list --json` - Keep human-readable marketplace list output unchanged - Add CLI coverage for configured local and git marketplace sources Example: ```json { "marketplaces": [ { "name": "debug", "root": "/path/to/.codex/.tmp/marketplaces/debug", "marketplaceSource": { "sourceType": "git", "source": "https://example.com/acme/agent-skills.git" } } ] } ``` ## Validation - `just fmt` - `just fix -p codex-cli` - `just test -p codex-cli marketplace_list` - `just test -p codex-cli` --- codex-rs/cli/src/marketplace_cmd.rs | 51 ++++++++++++- codex-rs/cli/src/plugin_cmd.rs | 4 +- codex-rs/cli/tests/plugin_cli.rs | 108 +++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index fc4d9eef62..b392bb53b9 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -5,7 +5,10 @@ use clap::Parser; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; +use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace::marketplace_root_dir; use codex_core_plugins::marketplace_add::MarketplaceAddOutcome; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; @@ -15,9 +18,14 @@ use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; +use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use crate::plugin_cmd::JsonMarketplaceSource; use crate::plugin_cmd::configured_marketplace_snapshot_issues; +use crate::plugin_cmd::configured_marketplace_sources; #[derive(Debug, Parser)] #[command(bin_name = "codex plugin marketplace")] @@ -240,7 +248,10 @@ async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceAr } let marketplaces = marketplace_listing.marketplaces; if args.json { - let output = JsonMarketplaceListOutput::from_marketplaces(marketplaces); + let marketplace_sources = + configured_marketplace_sources_by_root(config.codex_home.as_path(), &plugins_input); + let output = + JsonMarketplaceListOutput::from_marketplaces(marketplaces, &marketplace_sources); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -288,7 +299,10 @@ struct JsonMarketplaceListOutput { } impl JsonMarketplaceListOutput { - fn from_marketplaces(marketplaces: Vec) -> Self { + fn from_marketplaces( + marketplaces: Vec, + marketplace_sources: &HashMap, + ) -> Self { let mut seen_roots = HashSet::new(); let marketplaces = marketplaces .into_iter() @@ -298,6 +312,7 @@ impl JsonMarketplaceListOutput { return None; } Some(JsonMarketplaceListEntry { + marketplace_source: marketplace_sources.get(root.as_path()).cloned(), name: marketplace.name, root: root.display().to_string(), }) @@ -313,6 +328,38 @@ impl JsonMarketplaceListOutput { struct JsonMarketplaceListEntry { name: String, root: String, + #[serde(skip_serializing_if = "Option::is_none")] + marketplace_source: Option, +} + +fn configured_marketplace_sources_by_root( + codex_home: &Path, + plugins_input: &PluginsConfigInput, +) -> HashMap { + let marketplace_sources = configured_marketplace_sources(plugins_input); + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { + return HashMap::new(); + }; + let Some(marketplaces) = user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return HashMap::new(); + }; + + let default_install_root = marketplace_install_root(codex_home); + marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + let marketplace_source = marketplace_sources.get(marketplace_name)?; + let root = resolve_configured_marketplace_root( + marketplace_name, + marketplace, + &default_install_root, + )?; + Some((root, marketplace_source.clone())) + }) + .collect() } async fn run_upgrade( diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 7def7283c0..94c388066d 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -437,12 +437,12 @@ impl JsonPluginSource { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct JsonMarketplaceSource { +pub(crate) struct JsonMarketplaceSource { source_type: String, source: String, } -fn configured_marketplace_sources( +pub(crate) fn configured_marketplace_sources( plugins_input: &PluginsConfigInput, ) -> HashMap { let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index cda79cf9a1..c8bd7b5429 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -341,6 +341,7 @@ async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { #[tokio::test] async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; + let source_path = source.path().display().to_string(); let assert = codex_command(codex_home.path())? .args(["plugin", "marketplace", "list", "--json"]) @@ -355,7 +356,112 @@ async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { "marketplaces": [ { "name": "debug", - "root": source.path().display().to_string(), + "root": source_path, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_json_includes_configured_git_marketplace_source() -> Result<()> { + let codex_home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let normalized_root = canonicalize_existing_preserving_symlinks(&marketplace_root)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": normalized_root.display().to_string(), + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_json_keys_configured_source_by_root() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let normalized_root = canonicalize_existing_preserving_symlinks(&marketplace_root)?; + + let assert = codex_command(codex_home.path())? + .env("HOME", home.path()) + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": home.path().display().to_string(), + }, + { + "name": "debug", + "root": normalized_root.display().to_string(), + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, }, ], }) From 2375cb64493367c3f8ff3cb98fcff2b6350ac678 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 8 Jun 2026 11:47:23 -0700 Subject: [PATCH 119/141] ci: template custom runner names by repo (#27024) ## Why These workflows currently hard-code the `codex` runner group and custom runner labels. That makes the same workflow definitions less portable across repository copies or renamed repos, even though the runner fleet follows the repository name scheme. Template the runner identities from the repository name so `openai/codex` still resolves to the existing `codex-*` runners while other repos can use their own `-*` runner names. ## What Changed - Replaced custom runner `group` values such as `codex-runners` with `${{ github.event.repository.name }}-runners`. - Replaced custom runner labels such as `codex-linux-x64` and `codex-windows-arm64` with `${{ github.event.repository.name }}-...`. - Covered direct `runs-on` objects, matrix `runs_on` entries, reusable workflow runner inputs, and release runner labels. ## Verification - Parsed all `.github/workflows/*.yml` files as YAML with Ruby. - Searched `.github/workflows` to confirm no hardcoded runner-field `codex-runners` or `codex-*` labels remain. --- .github/workflows/bazel.yml | 16 ++--- .github/workflows/rust-ci-full.yml | 64 +++++++++---------- .github/workflows/rust-ci.yml | 4 +- .../rust-release-argument-comment-lint.yml | 4 +- .github/workflows/rust-release-windows.yml | 32 +++++----- .github/workflows/rust-release.yml | 8 +-- .github/workflows/sdk.yml | 8 +-- 7 files changed, 68 insertions(+), 68 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 809053069a..ffda87b8a5 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -152,8 +152,8 @@ jobs: - 3 - 4 runs-on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 environment: name: bazel @@ -267,8 +267,8 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' timeout-minutes: 40 runs-on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main) environment: name: bazel @@ -357,8 +357,8 @@ jobs: - os: windows-latest target: x86_64-pc-windows-gnullvm runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 runs-on: ${{ matrix.runs_on || matrix.os }} name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} environment: @@ -453,8 +453,8 @@ jobs: - os: windows-latest target: x86_64-pc-windows-gnullvm runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 runs-on: ${{ matrix.runs_on || matrix.os }} name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }} environment: diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index d22531baae..7ad1d4b3ad 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -112,8 +112,8 @@ jobs: - name: Windows runner: windows-x64 runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -185,38 +185,38 @@ jobs: target: x86_64-unknown-linux-musl profile: dev runs_on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu profile: dev runs_on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl profile: dev runs_on: - group: codex-runners - labels: codex-linux-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-arm64 - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu profile: dev runs_on: - group: codex-runners - labels: codex-linux-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc profile: dev runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: dev runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 # Also run representative release builds on Mac and Linux because # there could be release-only build errors we want to catch. @@ -229,26 +229,26 @@ jobs: target: x86_64-unknown-linux-musl profile: release runs_on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl profile: release runs_on: - group: codex-runners - labels: codex-linux-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc profile: release runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: release runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -479,8 +479,8 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: ubuntu-24.04 - runner_group: codex-runners - runner_labels: codex-linux-x64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-linux-x64 target: x86_64-unknown-linux-gnu profile: ci-test artifact_id: linux-x64-remote @@ -493,8 +493,8 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: ubuntu-24.04-arm - runner_group: codex-runners - runner_labels: codex-linux-arm64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-gnu profile: ci-test artifact_id: linux-arm64 @@ -506,8 +506,8 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: windows-x64 - runner_group: codex-runners - runner_labels: codex-windows-x64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-windows-x64 target: x86_64-pc-windows-msvc profile: ci-test artifact_id: windows-x64 @@ -519,11 +519,11 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: windows-arm64 - runner_group: codex-runners - runner_labels: codex-windows-arm64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-windows-arm64 archive_runner: windows-x64 - archive_runner_group: codex-runners - archive_runner_labels: codex-windows-x64 + archive_runner_group: ${{ github.event.repository.name }}-runners + archive_runner_labels: ${{ github.event.repository.name }}-windows-x64 target: aarch64-pc-windows-msvc profile: ci-test artifact_id: windows-arm64 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 4528f4d963..4cf6d6e37a 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -176,8 +176,8 @@ jobs: runner: windows-x64 timeout_minutes: 30 runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index 7f5ad01aa5..5bf1121925 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -57,8 +57,8 @@ jobs: runner_binary: argument-comment-lint.exe cargo_dylint_binary: cargo-dylint.exe runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 67c5c63ac9..e7574d85d2 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -29,43 +29,43 @@ jobs: bundle: primary binaries: "codex codex-responses-api-proxy" runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: primary binaries: "codex codex-responses-api-proxy" runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: helpers binaries: "codex-windows-sandbox-setup codex-command-runner" runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: helpers binaries: "codex-windows-sandbox-setup codex-command-runner" runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: app-server binaries: "codex-app-server" runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: app-server binaries: "codex-app-server" runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -162,13 +162,13 @@ jobs: - runner: windows-x64 target: x86_64-pc-windows-msvc runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 5dbf1b77eb..b821700b49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -101,25 +101,25 @@ jobs: binaries: "codex-app-server" build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl + - runner: ${{ github.event.repository.name }}-linux-x64-xl target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: codex-linux-x64-xl + - runner: ${{ github.event.repository.name }}-linux-x64-xl target: x86_64-unknown-linux-musl bundle: app-server artifact_name: x86_64-unknown-linux-musl-app-server binaries: "codex-app-server" build_dmg: "false" - - runner: codex-linux-arm64 + - runner: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: codex-linux-arm64 + - runner: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-musl bundle: app-server artifact_name: aarch64-unknown-linux-musl-app-server diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 42a44afdb5..3cf6d7c8c3 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -8,8 +8,8 @@ on: jobs: python-sdk: runs-on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 timeout-minutes: 10 steps: - name: Checkout repository @@ -43,8 +43,8 @@ jobs: sdks: runs-on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 timeout-minutes: 10 environment: name: bazel From 9e0d7f02c9416c46dde6e571068a0fb03a4facdf Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 8 Jun 2026 11:59:50 -0700 Subject: [PATCH 120/141] fix: preserve auto review across config and delegation (#26230) ## Why Auto Review should remain the effective approval reviewer when settings cross runtime boundaries. A config or app-server round trip must not change the reviewer identity, and delegated work must not silently fall back to user review. This requires both a stable canonical serialized value and propagation of the effective setting. `auto_review` is the canonical value across protocol and app-server output, while `guardian_subagent` remains accepted as backward-compatible input. ## What changed - serialize `ApprovalsReviewer::AutoReview` consistently as `auto_review` across core protocol and app-server v2 - continue accepting `guardian_subagent` when reading existing config or client requests - carry the active turn's approval reviewer into spawned agents - update config/debug expectations and add delegated-task regression coverage ## Scope This does not change Guardian policy or remove compatibility with existing `guardian_subagent` inputs. It preserves the selected reviewer across serialization, config reloads, app-server settings, and delegated task setup. Related Guardian changes are split independently: - #26231 adds denials and soft denials - #26334 retries transient reviewer failures - #26333 reuses narrowly scoped low-risk approvals - #26232 adds TUI denial recovery ## Validation - `just test -p codex-app-server-protocol` (224 passed) - regression coverage for delegated task reviewer propagation - serialization coverage for canonical `auto_review` output and legacy `guardian_subagent` input --------- Co-authored-by: saud-oai --- codex-rs/app-server-protocol/src/protocol/v2/shared.rs | 2 +- codex-rs/app-server-protocol/src/protocol/v2/tests.rs | 2 +- codex-rs/config/src/loader/mod.rs | 2 +- codex-rs/core/src/tools/handlers/multi_agents_common.rs | 1 + codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 5 +++++ codex-rs/protocol/src/config_types.rs | 6 +++--- codex-rs/tui/src/app/tests.rs | 4 ++-- codex-rs/tui/src/debug_config.rs | 4 ++-- 8 files changed, 16 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index 9ec1fb80cb..cb881d6267 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -233,7 +233,7 @@ impl From for AskForApproval { pub enum ApprovalsReviewer { #[serde(rename = "user")] User, - #[serde(rename = "guardian_subagent", alias = "auto_review")] + #[serde(rename = "auto_review", alias = "guardian_subagent")] AutoReview, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index d064795f2b..91bfbcc091 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -65,7 +65,7 @@ fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagen ); assert_eq!( serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), - "\"guardian_subagent\"" + "\"auto_review\"" ); for value in ["user", "auto_review", "guardian_subagent"] { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index bc0fb482e4..1a79e984e3 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1467,7 +1467,7 @@ foo = "xyzzy" TomlValue::Table(toml::map::Map::from_iter([( "allowed_approvals_reviewers".to_string(), TomlValue::Array(vec![ - TomlValue::String("guardian_subagent".to_string()), + TomlValue::String("auto_review".to_string()), TomlValue::String("user".to_string()), ]), )])) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 8563ed09cb..5958892152 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -262,6 +262,7 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( .map_err(|err| { FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) })?; + config.approvals_reviewer = turn.config.approvals_reviewer; config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); #[allow(deprecated)] 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 4f514e23a7..ac78113a30 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -24,6 +24,7 @@ use codex_model_provider::create_model_provider; use codex_model_provider_info::built_in_model_providers; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::BaseInstructions; @@ -2331,6 +2332,9 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { turn.approval_policy .set(AskForApproval::OnRequest) .expect("approval policy should be set"); + let mut config = (*turn.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + set_turn_config(&mut turn, config); turn.permission_profile = expected_permission_profile.clone(); assert_ne!( expected_permission_profile, @@ -2370,6 +2374,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { .await; assert_eq!(snapshot.sandbox_policy(), expected_sandbox); assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest); + assert_eq!(snapshot.approvals_reviewer, ApprovalsReviewer::AutoReview); assert_eq!(snapshot.permission_profile, expected_permission_profile); let child_thread = manager .get_thread(agent_id) diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 6654f3166b..713945dbde 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -166,8 +166,8 @@ pub enum ApprovalsReviewer { #[default] #[serde(rename = "user")] User, - #[serde(rename = "guardian_subagent", alias = "auto_review")] - #[strum(serialize = "guardian_subagent")] + #[serde(rename = "auto_review", alias = "guardian_subagent")] + #[strum(serialize = "auto_review")] AutoReview, } @@ -760,7 +760,7 @@ mod tests { ); assert_eq!( serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), - "\"guardian_subagent\"" + "\"auto_review\"" ); for value in ["user", "auto_review", "guardian_subagent"] { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ac70930a3b..0be0a1462f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1824,7 +1824,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(config.contains("guardian_approval = true")); - assert!(config.contains("approvals_reviewer = \"guardian_subagent\"")); + assert!(config.contains("approvals_reviewer = \"auto_review\"")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); app_server.shutdown().await?; @@ -1988,7 +1988,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains("approvals_reviewer = \"guardian_subagent\"")); + assert!(config.contains("approvals_reviewer = \"auto_review\"")); assert!(config.contains("guardian_approval = true")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index de5f79a676..620b152b99 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -755,7 +755,7 @@ mod tests { "allowed_approval_policies: on-request (source: {requirements_source})" ))); assert!(rendered.contains( - "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" + "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); assert!( rendered.contains( @@ -818,7 +818,7 @@ mod tests { let rendered = render_to_text(&render_debug_config_lines(&stack)); assert!(rendered.contains( - "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" + "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); assert!(!rendered.contains("Requirements:\n ")); } From f3a807497590a8b86ef44480606f92cc71cf52f1 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 8 Jun 2026 13:09:06 -0700 Subject: [PATCH 121/141] [codex] Clarify PR babysitter state mutations (#27038) # Why Codex is doing a bit too much on my PRs that it's babysitting. In particular I'd like it to not interact with comment threads that involve other humans -- I should be the one doing human interaction. This is tricky because it's still very useful to be able to drop review comments myself and have Codex iterate on them. ## What This updates `.codex/skills/babysit-pr/SKILL.md` with an explicit GitHub state mutation policy. --- .codex/skills/babysit-pr/SKILL.md | 33 +++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.codex/skills/babysit-pr/SKILL.md b/.codex/skills/babysit-pr/SKILL.md index 1b95144297..68fc7278ba 100644 --- a/.codex/skills/babysit-pr/SKILL.md +++ b/.codex/skills/babysit-pr/SKILL.md @@ -29,7 +29,7 @@ Accept any of the following: 4. If `diagnose_ci_failure` is present, inspect failed run logs and classify the failure. 5. If the failure is likely caused by the current branch, patch code locally, commit, and push. Do not patch random flaky tests, CI infrastructure, dependency outages, runner issues, or other failures that are unrelated to the branch. 6. If `process_review_comment` is present, inspect surfaced review items and decide whether to address them. -7. If a review item is actionable and correct, patch code locally, commit, push, and then mark the associated review thread/comment as resolved once the fix is on GitHub. +7. If a review item is actionable and correct, patch code locally, commit, push, and then resolve the associated review thread only when allowed by the GitHub state mutation policy below. 8. Do not post replies to human-authored review comments/threads unless the user explicitly confirms the exact response. If a human review item is non-actionable, already addressed, or not valid, surface the item and recommended response to the user instead of replying on GitHub. 9. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`. 10. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change. @@ -101,7 +101,7 @@ When you agree with a comment and it is actionable: 1. Patch code locally. 2. Commit with `codex: address PR review feedback (#)`. 3. Push to the PR head branch. -4. After the push succeeds, mark the associated GitHub review thread/comment as resolved. +4. After the push succeeds, resolve the associated GitHub review thread only when allowed by the GitHub state mutation policy below. 5. Resume watching on the new SHA immediately (do not stop after reporting the push). 6. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again. @@ -109,6 +109,31 @@ Do not post replies to human-authored GitHub review comments/threads automatical If the watcher later surfaces your own approved reply because the authenticated operator is treated as a trusted review author, treat that self-authored item as already handled and do not reply again. If a code review comment/thread is already marked as resolved in GitHub, treat it as non-actionable and safely ignore it unless new unresolved follow-up feedback appears. +## GitHub State Mutation Policy + +You can read any PR state you need for monitoring. Writes must comply with this policy. + +You can push PRs to update the code under review or to force CI re-runs as described above. + +You can resolve review comment threads from the human who requested babysitting or from the Codex +review bot. When resolving, leave a comment prefixed with `[from Codex]: ` and explain what changes +you made and which commit includes them. Don't touch review threads if other humans other than the +user who requested babysitting have participated. + +Before making any changes, fetch the PR state yourself instead of relying on the PR watcher script's +output. + +Unless explicitly asked, do not: + +* comment on other humans' review threads, communicate with the user in chat instead +* resolve review threads from humans other than the user +* interact with humans other than the user +* mark PRs as drafts or ready for review +* close or reopen PRs + +In general, never act on GitHub in ways that would make it hard to tell whether you or the user did +something visible to other humans. When in doubt, ask the user for clarification in chat. + ## Git Safety Rules - Work only on the PR head branch. @@ -133,10 +158,10 @@ Use this loop in a live Codex session: 3. First check whether the PR is now merged or otherwise closed; if so, report that terminal state and stop polling immediately. 4. Check CI summary, new review items, and mergeability/conflict status. 5. Diagnose CI failures and classify branch-related vs flaky/unrelated. If the overall run is still pending but `failed_jobs` already includes a failed job, fetch that job's logs and diagnose immediately instead of waiting for the whole workflow run to finish. Patch only when the failure is branch-related. -6. For each surfaced review item from another author, patch/commit/push and then resolve it if it is actionable. If it is non-actionable, already addressed, or requires a written answer, surface it to the user with a suggested response instead of posting automatically. If a later snapshot surfaces your own approved reply, treat it as informational and continue without responding again. +6. For each surfaced review item from another author, patch/commit/push if it is actionable, then resolve it only when allowed by the GitHub state mutation policy above. If it is non-actionable, already addressed, or requires a written answer, surface it to the user with a suggested response instead of posting automatically. If a later snapshot surfaces your own approved reply, treat it as informational and continue without responding again. 7. Process actionable review comments before flaky reruns when both are present; if a review fix requires a commit, push it and skip rerunning failed checks on the old SHA. 8. Retry failed checks only when `retry_failed_checks` is present and you are not about to replace the current SHA with a review/CI fix commit. Do not make code changes for unrelated flakes or infrastructure failures just to get CI green. -9. If you pushed a commit, resolved a review thread, or triggered a rerun, report the action briefly and continue polling (do not stop). If a human review comment needs a written GitHub response, stop and ask for confirmation before posting. +9. If you pushed a commit, resolved an eligible review thread, or triggered a rerun, report the action briefly and continue polling (do not stop). If a human review comment needs a written GitHub response, stop and ask for confirmation before posting. 10. After a review-fix push, proactively restart continuous monitoring (`--watch`) in the same turn unless a strict stop condition has already been reached. 11. If everything is passing, mergeable, not blocked on required review approval, and there are no unaddressed review items, report that the PR is currently ready to merge but keep the watcher running so new review comments are surfaced quickly while the PR remains open. 12. If blocked on a user-help-required issue (infra outage, exhausted flaky retries, unclear reviewer request, permissions), report the blocker and stop. From f9a680b9075562093ff78e45ff4fcb2e9a0348f9 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 8 Jun 2026 22:32:10 +0200 Subject: [PATCH 122/141] [codex] Calm multi-agent v2 usage prompts (#27037) ## Summary - tighten the default multi-agent v2 root and subagent usage hints to bias toward local work - add a pre-call gate to the v2 spawn_agent description for independent, bounded, parallelizable subtasks ## Validation - just fmt - started just test -p codex-core, but it was interrupted before completion per follow-up request to commit and push immediately --- codex-rs/core/src/config/mod.rs | 4 ++-- codex-rs/core/src/tools/handlers/multi_agents_spec.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fd74fcf2d2..3f7a7370a0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -202,7 +202,7 @@ All agents in the team, including the agents that you can assign tasks to, are e You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. Child agents can also spawn their own sub-agents. You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. -Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. +Default to doing the work yourself. Spawn sub-agents only for concrete, bounded subtasks that can run independently alongside useful local work and are likely to materially shorten completion time. Do not delegate simple tasks, small edits, routine searches, or work you can complete quickly yourself. You will receive messages in the analysis channel in the form: ``` @@ -219,7 +219,7 @@ You can spawn sub-agents to handle subtasks, and those sub-agents can spawn thei You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. Child agents can also spawn their own sub-agents. -Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. +Default to doing the work yourself. Spawn sub-agents only for concrete, bounded subtasks that can run independently alongside useful local work and are likely to materially shorten completion time. Do not delegate simple tasks, small edits, routine searches, or work you can complete quickly yourself. When you provide a response in the final channel, that content is immediately delivered back to your parent agent. diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 8455b13036..8f0d9d56b9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -741,6 +741,7 @@ fn spawn_agent_tool_description_v2( You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`. The spawned agent will have the same tools as you and the ability to spawn its own subagents. {inherited_model_guidance} +Only call this tool for a concrete, bounded subtask that can run independently alongside useful local work; otherwise continue locally. It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes. The new agent's canonical task name will be provided to it along with the message. {concurrency_guidance}"# From f3c1283411edadcc0522bea376d0adc6961d5ca3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 8 Jun 2026 13:55:15 -0700 Subject: [PATCH 123/141] Pair thread environment settings (#26687) ## Why Thread cwd and environment selections are a single logical setting in core: updating one without the other can silently desynchronize the next-turn execution context. This change makes that relationship explicit in the internal thread settings flow while preserving the existing app-server public API shape. ## What changed - Moved the cwd/environment pair through internal `ThreadSettingsOverrides.environment_settings` instead of a top-level internal `cwd` field. - Kept `thread/settings/update` public params unchanged, with app-server translating top-level `cwd` into the paired internal settings shape. - Moved `Op::UserInput` environment overrides into thread settings so user turns and settings updates use the same core path. - Updated core, app-server, MCP, memories, sample, and test callsites to construct the paired settings shape. ## Verification - `git diff --check` - Local test run starting after PR creation. --- .../src/protocol/v2/turn.rs | 2 +- .../app-server/src/bespoke_event_handling.rs | 4 +- codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/apps_processor.rs | 2 +- .../request_processors/thread_lifecycle.rs | 5 +- .../request_processors/thread_processor.rs | 15 +- .../thread_processor_tests.rs | 3 +- .../src/request_processors/thread_summary.rs | 46 +-- .../src/request_processors/turn_processor.rs | 55 +++- codex-rs/core/src/agent/control_tests.rs | 3 - codex-rs/core/src/codex_delegate.rs | 1 - codex-rs/core/src/codex_thread.rs | 19 +- codex-rs/core/src/guardian/review_session.rs | 14 +- codex-rs/core/src/session/handlers.rs | 9 +- codex-rs/core/src/session/mod.rs | 17 +- codex-rs/core/src/session/session.rs | 70 ++--- codex-rs/core/src/session/tests.rs | 262 +++++++++--------- codex-rs/core/src/session/turn_context.rs | 51 +--- codex-rs/core/src/thread_manager_tests.rs | 94 ------- .../src/tools/handlers/multi_agents_tests.rs | 1 - codex-rs/core/tests/common/test_codex.rs | 18 +- codex-rs/core/tests/suite/abort_tasks.rs | 5 - .../core/tests/suite/additional_context.rs | 9 - codex-rs/core/tests/suite/apply_patch_cli.rs | 27 +- codex-rs/core/tests/suite/approvals.rs | 13 +- codex-rs/core/tests/suite/auto_review.rs | 4 +- codex-rs/core/tests/suite/client.rs | 40 +-- .../core/tests/suite/client_websockets.rs | 2 - codex-rs/core/tests/suite/code_mode.rs | 6 +- .../tests/suite/collaboration_instructions.rs | 24 +- codex-rs/core/tests/suite/compact.rs | 39 +-- codex-rs/core/tests/suite/compact_remote.rs | 63 +---- .../core/tests/suite/compact_remote_parity.rs | 1 - .../core/tests/suite/compact_resume_fork.rs | 4 +- codex-rs/core/tests/suite/exec_policy.rs | 7 +- codex-rs/core/tests/suite/fork_thread.rs | 2 - codex-rs/core/tests/suite/guardian_review.rs | 4 +- codex-rs/core/tests/suite/hooks.rs | 2 - codex-rs/core/tests/suite/image_rollout.rs | 7 +- codex-rs/core/tests/suite/items.rs | 13 +- codex-rs/core/tests/suite/json_result.rs | 4 +- .../core/tests/suite/mcp_turn_metadata.rs | 4 +- .../tests/suite/model_runtime_selectors.rs | 2 - codex-rs/core/tests/suite/model_switching.rs | 4 +- .../core/tests/suite/model_visible_layout.rs | 21 +- codex-rs/core/tests/suite/models_cache_ttl.rs | 4 +- .../core/tests/suite/models_etag_responses.rs | 4 +- codex-rs/core/tests/suite/otel.rs | 22 -- codex-rs/core/tests/suite/override_updates.rs | 3 +- codex-rs/core/tests/suite/pending_input.rs | 7 +- .../core/tests/suite/permissions_messages.rs | 15 - codex-rs/core/tests/suite/personality.rs | 4 +- codex-rs/core/tests/suite/plugins.rs | 3 - codex-rs/core/tests/suite/prompt_caching.rs | 26 +- codex-rs/core/tests/suite/quota_exceeded.rs | 1 - .../core/tests/suite/realtime_conversation.rs | 3 - codex-rs/core/tests/suite/remote_env.rs | 33 +-- codex-rs/core/tests/suite/remote_models.rs | 12 +- .../core/tests/suite/request_compression.rs | 2 - .../core/tests/suite/request_permissions.rs | 4 +- .../tests/suite/request_permissions_tool.rs | 4 +- .../core/tests/suite/request_user_input.rs | 10 +- .../suite/responses_api_proxy_headers.rs | 4 +- codex-rs/core/tests/suite/resume.rs | 7 - codex-rs/core/tests/suite/review.rs | 4 +- codex-rs/core/tests/suite/rmcp_client.rs | 4 +- .../tests/suite/safety_check_downgrade.rs | 4 +- codex-rs/core/tests/suite/search_tool.rs | 4 - codex-rs/core/tests/suite/shell_snapshot.rs | 13 +- codex-rs/core/tests/suite/skill_approval.rs | 4 +- codex-rs/core/tests/suite/skills.rs | 4 +- codex-rs/core/tests/suite/sqlite_state.rs | 5 +- .../suite/stream_error_allows_next_turn.rs | 2 - .../core/tests/suite/stream_no_completed.rs | 1 - .../tests/suite/subagent_notifications.rs | 4 +- codex-rs/core/tests/suite/tool_harness.rs | 16 +- codex-rs/core/tests/suite/tool_parallelism.rs | 7 +- codex-rs/core/tests/suite/tools.rs | 7 +- codex-rs/core/tests/suite/truncation.rs | 4 +- codex-rs/core/tests/suite/unified_exec.rs | 22 +- .../suite/unified_exec_zsh_fork_approvals.rs | 4 +- .../core/tests/suite/user_notification.rs | 1 - codex-rs/core/tests/suite/user_shell_cmd.rs | 4 +- codex-rs/core/tests/suite/view_image.rs | 15 +- .../core/tests/suite/websocket_fallback.rs | 4 +- codex-rs/core/tests/suite/window_headers.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 2 - codex-rs/memories/write/src/runtime.rs | 1 - codex-rs/protocol/src/protocol.rs | 35 ++- codex-rs/thread-manager-sample/src/main.rs | 1 - 90 files changed, 521 insertions(+), 820 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 3bac7bf7ec..84567fb4c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -76,7 +76,7 @@ pub struct TurnStartParams { #[experimental("turn/start.additionalContext")] #[ts(optional = nullable)] pub additional_context: Option>, - /// Optional turn-scoped environments. + /// Optional environments for this turn and subsequent turns. /// /// Omitted uses the thread sticky environments. Empty disables /// environment access for this turn. Non-empty selects the first diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 547d0666ed..4549eab68d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -767,7 +767,7 @@ pub(crate) async fn apply_bespoke_event_handling( let requested_permissions = request.permissions.clone(); let request_cwd = match request.cwd.clone() { Some(cwd) => cwd, - None => conversation.config_snapshot().await.cwd, + None => conversation.config_snapshot().await.cwd().clone(), }; let params = PermissionsRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -1161,7 +1161,7 @@ pub(crate) async fn apply_bespoke_event_handling( return; } }; - let fallback_cwd = conversation.config_snapshot().await.cwd; + let fallback_cwd = conversation.config_snapshot().await.cwd().clone(); let stored_thread = match conversation .read_thread( /*include_archived*/ true, /*include_history*/ true, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 79da49fba4..d4e9395627 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -330,6 +330,7 @@ use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginS use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; use codex_features::Feature; @@ -400,6 +401,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; #[cfg(test)] use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 49a6615f1c..4a665d8ef3 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -53,7 +53,7 @@ impl AppsRequestProcessor { None }; let fallback_cwd = match thread.as_ref() { - Some(thread) => Some(thread.config_snapshot().await.cwd.to_path_buf()), + Some(thread) => Some(thread.config_snapshot().await.cwd().to_path_buf()), None => None, }; let mut config = self.load_latest_config(fallback_cwd).await?; diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 8bfb282ace..fd9e93e189 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -627,6 +627,8 @@ pub(super) async fn handle_pending_thread_resume_request( } } + let config_snapshot = pending.config_snapshot; + let cwd = config_snapshot.cwd().clone(); let ThreadConfigSnapshot { model, model_provider_id, @@ -635,11 +637,10 @@ pub(super) async fn handle_pending_thread_resume_request( approvals_reviewer, permission_profile, active_permission_profile, - cwd, workspace_roots, reasoning_effort, .. - } = pending.config_snapshot; + } = config_snapshot; let instruction_sources = pending.instruction_sources; let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index eab606bf62..4cc169f80f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -47,11 +47,11 @@ fn collect_resume_override_mismatches( } if let Some(requested_cwd) = request.cwd.as_deref() { let requested_cwd_path = std::path::PathBuf::from(requested_cwd); - if requested_cwd_path != config_snapshot.cwd.as_path() { + if requested_cwd_path != config_snapshot.cwd().as_path() { mismatch_details.push(format!( "cwd requested={} active={}", requested_cwd_path.display(), - config_snapshot.cwd.display() + config_snapshot.cwd().display() )); } } @@ -1154,8 +1154,9 @@ impl ThreadRequestProcessor { let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ); + let cwd = config_snapshot.cwd().clone(); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -1164,7 +1165,7 @@ impl ThreadRequestProcessor { model: config_snapshot.model, model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, - cwd: config_snapshot.cwd, + cwd, runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), @@ -2601,7 +2602,7 @@ impl ThreadRequestProcessor { let config_snapshot = codex_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ); let active_permission_profile = thread_response_active_permission_profile( config_snapshot.active_permission_profile, @@ -3335,7 +3336,7 @@ impl ThreadRequestProcessor { let config_snapshot = forked_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -4205,7 +4206,7 @@ fn build_thread_from_snapshot( updated_at: now, status: ThreadStatus::NotLoaded, path, - cwd: config_snapshot.cwd.clone(), + cwd: config_snapshot.cwd().clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index c0a909e577..49941dd839 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -77,6 +77,7 @@ mod thread_processor_behavior_tests { use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; + use codex_protocol::protocol::TurnEnvironmentSelections; use codex_state::ThreadMetadataBuilder; use codex_thread_store::StoredThread; use codex_utils_absolute_path::test_support::PathBufExt; @@ -685,7 +686,7 @@ mod thread_processor_behavior_tests { approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, - cwd, + environments: TurnEnvironmentSelections::new(cwd, Vec::new()), workspace_roots: Vec::new(), profile_workspace_roots: Vec::new(), ephemeral: false, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index f467074828..0fb1320055 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -190,12 +190,12 @@ pub(crate) fn thread_settings_from_config_snapshot( config_snapshot: &ThreadConfigSnapshot, ) -> ThreadSettings { ThreadSettings { - cwd: config_snapshot.cwd.clone(), + cwd: config_snapshot.cwd().clone(), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox_policy: thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), + config_snapshot.cwd().as_path(), ), active_permission_profile: thread_response_active_permission_profile( config_snapshot.active_permission_profile.clone(), @@ -213,24 +213,36 @@ pub(crate) fn thread_settings_from_config_snapshot( pub(crate) fn thread_settings_from_core_snapshot( snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, ) -> ThreadSettings { + let codex_protocol::protocol::ThreadSettingsSnapshot { + model, + model_provider_id, + service_tier, + approval_policy, + approvals_reviewer, + permission_profile, + active_permission_profile, + cwd, + reasoning_effort, + reasoning_summary, + personality, + collaboration_mode, + } = snapshot; + let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); ThreadSettings { - sandbox_policy: thread_response_sandbox_policy( - &snapshot.permission_profile, - snapshot.cwd.as_path(), - ), - cwd: snapshot.cwd, - approval_policy: snapshot.approval_policy.into(), - approvals_reviewer: snapshot.approvals_reviewer.into(), + sandbox_policy, + cwd, + approval_policy: approval_policy.into(), + approvals_reviewer: approvals_reviewer.into(), active_permission_profile: thread_response_active_permission_profile( - snapshot.active_permission_profile, + active_permission_profile, ), - model: snapshot.model, - model_provider: snapshot.model_provider_id, - service_tier: snapshot.service_tier, - effort: snapshot.reasoning_effort, - summary: snapshot.reasoning_summary, - collaboration_mode: snapshot.collaboration_mode, - personality: snapshot.personality, + model, + model_provider: model_provider_id, + service_tier, + effort: reasoning_effort, + summary: reasoning_summary, + collaboration_mode, + personality, } } diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index ce98a2e8e0..7bdd9df0d8 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -43,7 +43,7 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, - cwd: Option, + environments: Option, runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, @@ -406,12 +406,14 @@ impl TurnRequestProcessor { let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); let cwd = resolve_request_cwd(params.cwd)?; + let environments = + Self::build_environment_override(thread.as_ref(), cwd, environment_selections).await; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "turn/start", - cwd, + environments, runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, @@ -430,7 +432,6 @@ impl TurnRequestProcessor { // Start the turn by submitting the user input. Return its submission id as turn_id. let turn_op = Op::UserInput { items: mapped_items, - environments: environment_selections, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, additional_context, @@ -478,6 +479,31 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn build_environment_override( + thread: &CodexThread, + cwd: Option, + environment_selections: Option>, + ) -> Option { + if cwd.is_none() && environment_selections.is_none() { + return None; + } + + let snapshot = thread.config_snapshot().await; + let environment_selections = + environment_selections.unwrap_or_else(|| snapshot.environment_selections().to_vec()); + let legacy_fallback_cwd = cwd.unwrap_or_else(|| { + environment_selections + .iter() + .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) + .map(|selection| selection.cwd.clone()) + .unwrap_or_else(|| snapshot.cwd().clone()) + }); + Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )) + } + async fn build_thread_settings_overrides( &self, thread: &CodexThread, @@ -485,7 +511,7 @@ impl TurnRequestProcessor { ) -> Result { let ThreadSettingsBuildParams { method, - cwd, + environments, runtime_workspace_roots, approval_policy, approvals_reviewer, @@ -508,6 +534,7 @@ impl TurnRequestProcessor { let collaboration_mode = collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); let runtime_workspace_roots_request = runtime_workspace_roots; + let has_environment_override = environments.is_some(); // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for // `thread/settings/updated` or combine the fields in one request. @@ -517,7 +544,7 @@ impl TurnRequestProcessor { None }; - let has_any_overrides = cwd.is_some() + let has_any_overrides = has_environment_override || runtime_workspace_roots_request.is_some() || approval_policy.is_some() || approvals_reviewer.is_some() @@ -545,7 +572,9 @@ impl TurnRequestProcessor { ))); }; let overrides = ConfigOverrides { - cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), + cwd: environments + .as_ref() + .map(|environments| environments.legacy_fallback_cwd.to_path_buf()), workspace_roots: Some( runtime_workspace_roots .clone() @@ -561,7 +590,7 @@ impl TurnRequestProcessor { .load_for_cwd( /*request_overrides*/ None, overrides, - Some(snapshot.cwd.to_path_buf()), + Some(snapshot.cwd().to_path_buf()), ) .await .map_err(|err| config_load_error(&err))?; @@ -588,7 +617,7 @@ impl TurnRequestProcessor { if has_any_overrides { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { - cwd: cwd.clone(), + environments: environments.clone(), workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, @@ -611,7 +640,7 @@ impl TurnRequestProcessor { } Ok(codex_protocol::protocol::ThreadSettingsOverrides { - cwd, + environments, workspace_roots: runtime_workspace_roots, profile_workspace_roots, approval_policy, @@ -636,12 +665,18 @@ impl TurnRequestProcessor { ) -> Result { let (_, thread) = self.load_thread(¶ms.thread_id).await?; let cwd = resolve_request_cwd(params.cwd)?; + let environments = Self::build_environment_override( + thread.as_ref(), + cwd, + /*environment_selections*/ None, + ) + .await; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "thread/settings/update", - cwd, + environments, runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3ad1360e5a..c626dda4a5 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -458,7 +458,6 @@ async fn send_input_submits_user_message() { let expected = ( thread_id, Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), @@ -797,7 +796,6 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { let expected = ( thread_id, Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "spawned".to_string(), text_elements: Vec::new(), @@ -1017,7 +1015,6 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let expected = ( child_thread_id, Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "child task".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5a1473b057..5f816bd075 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -196,7 +196,6 @@ pub(crate) async fn run_codex_thread_one_shot( // Send the initial input to kick off the one-shot turn. io.submit(Op::UserInput { - environments: None, items: input, final_output_json_schema, responsesapi_client_metadata: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1697f768e2..ab1f6df03e 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; use codex_thread_store::StoredThread; @@ -59,7 +60,7 @@ pub struct ThreadConfigSnapshot { pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, pub active_permission_profile: Option, - pub cwd: AbsolutePathBuf, + pub environments: TurnEnvironmentSelections, pub workspace_roots: Vec, pub profile_workspace_roots: Vec, pub ephemeral: bool, @@ -114,10 +115,18 @@ impl TryStartTurnIfIdleError { } impl ThreadConfigSnapshot { + pub fn cwd(&self) -> &AbsolutePathBuf { + &self.environments.legacy_fallback_cwd + } + + pub fn environment_selections(&self) -> &[TurnEnvironmentSelection] { + &self.environments.environments + } + pub fn sandbox_policy(&self) -> SandboxPolicy { codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &self.permission_profile, - self.cwd.as_path(), + self.cwd().as_path(), ) } } @@ -125,7 +134,7 @@ impl ThreadConfigSnapshot { /// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { - pub cwd: Option, + pub environments: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, @@ -330,7 +339,7 @@ impl CodexThread { overrides: CodexThreadSettingsOverrides, ) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, @@ -357,7 +366,7 @@ impl CodexThread { }; SessionSettingsUpdate { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 8504690b29..fcda9a5a23 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -721,19 +721,27 @@ async fn run_review_on_session( .await .unwrap_or_default(); let guardian_permission_profile = PermissionProfile::read_only(); + let parent_turn_environments = params.parent_turn.environments.to_selections(); + let parent_turn_legacy_fallback_cwd = params + .parent_turn + .environments + .primary() + .map(|environment| environment.cwd.clone()) + .unwrap_or_else(|| params.parent_turn.config.cwd.clone()); let submit_result = run_before_review_deadline( deadline, params.external_cancel.as_ref(), Box::pin(review_session.codex.submit(Op::UserInput { items: prompt_items.items, - environments: None, final_output_json_schema: Some(params.schema.clone()), responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - #[allow(deprecated)] - cwd: Some(params.parent_turn.cwd.clone()), + environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( + parent_turn_legacy_fallback_cwd, + parent_turn_environments, + )), approval_policy: Some(AskForApproval::Never), sandbox_policy: None, permission_profile: Some(guardian_permission_profile), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 7730a30ad7..c0f62ec664 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -122,7 +122,7 @@ async fn thread_settings_update( thread_settings: ThreadSettingsOverrides, ) -> SessionSettingsUpdate { let ThreadSettingsOverrides { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, @@ -151,7 +151,7 @@ async fn thread_settings_update( } }; SessionSettingsUpdate { - cwd, + environments, workspace_roots, profile_workspace_roots, approval_policy, @@ -173,6 +173,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { let state = sess.state.lock().await; state.session_configuration.thread_config_snapshot() }; + let cwd = snapshot.cwd().clone(); EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent { thread_settings: ThreadSettingsSnapshot { model: snapshot.model, @@ -182,7 +183,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { approvals_reviewer: snapshot.approvals_reviewer, permission_profile: snapshot.permission_profile, active_permission_profile: snapshot.active_permission_profile, - cwd: snapshot.cwd, + cwd, reasoning_effort: snapshot.reasoning_effort, reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, @@ -200,7 +201,6 @@ pub(super) async fn user_input_or_turn_inner( ) { let Op::UserInput { items, - environments, final_output_json_schema, responsesapi_client_metadata, additional_context, @@ -216,7 +216,6 @@ pub(super) async fn user_input_or_turn_inner( SessionSettingsUpdate::default() }; updates.final_output_json_schema = Some(final_output_json_schema); - updates.environments = environments; let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { // new_turn_with_sub_id already emits the error event. diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 37d55256f5..f3e506d40f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -116,6 +116,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -602,11 +603,13 @@ impl Codex { approvals_reviewer: config.approvals_reviewer, permission_profile_state: session_permission_profile_state_from_config(&config)?, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new( + config.cwd.clone(), + environment_selections.to_selections(), + ), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: environment_selections.to_selections(), original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, @@ -814,7 +817,10 @@ impl Codex { pub(crate) async fn thread_environment_selections(&self) -> Vec { let state = self.session.state.lock().await; - state.session_configuration.environments.clone() + state + .session_configuration + .environment_selections() + .to_vec() } pub(crate) fn state_db(&self) -> Option { @@ -1114,7 +1120,6 @@ impl Session { self, self.next_internal_sub_id(), Op::UserInput { - environments: None, items: vec![UserInput::Text { text, text_elements: Vec::new(), @@ -1414,12 +1419,12 @@ impl Session { .then(|| Self::build_effective_session_config(&state.session_configuration)); let new_config = notify_config_contributors.then(|| Self::build_effective_session_config(&updated)); - let previous_cwd = state.session_configuration.cwd.clone(); + let previous_cwd = state.session_configuration.cwd().clone(); let previous_permission_profile = state.session_configuration.permission_profile(); let updated_permission_profile = updated.permission_profile(); let permission_profile_changed = previous_permission_profile != updated_permission_profile; - let next_cwd = updated.cwd.clone(); + let next_cwd = updated.cwd().clone(); let codex_home = updated.codex_home.clone(); let session_source = updated.session_source.clone(); state.session_configuration = updated; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 92f9737310..37a501fb38 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -12,6 +12,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use std::sync::OnceLock; use tokio::sync::Semaphore; @@ -75,11 +76,9 @@ pub(crate) struct SessionConfiguration { pub(super) permission_profile_state: PermissionProfileState, pub(super) windows_sandbox_level: WindowsSandboxLevel, - /// 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. - pub(super) cwd: AbsolutePathBuf, + /// Sticky thread-level environment selections plus the legacy cwd used + /// when a turn does not select an environment. + pub(super) environments: TurnEnvironmentSelections, /// Thread-scoped runtime workspace roots for materializing symbolic /// workspace permissions at session runtime. pub(super) workspace_roots: Vec, @@ -87,8 +86,6 @@ pub(crate) struct SessionConfiguration { pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. pub(super) thread_name: Option, - /// Sticky environments for turns that do not provide a turn-local override. - pub(super) environments: Vec, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -110,6 +107,14 @@ pub(crate) struct SessionConfiguration { } impl SessionConfiguration { + pub(super) fn cwd(&self) -> &AbsolutePathBuf { + &self.environments.legacy_fallback_cwd + } + + pub(super) fn environment_selections(&self) -> &[TurnEnvironmentSelection] { + &self.environments.environments + } + pub(crate) fn codex_home(&self) -> &AbsolutePathBuf { &self.codex_home } @@ -153,7 +158,7 @@ impl SessionConfiguration { let permission_profile = self.permission_profile(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &permission_profile, - &self.cwd, + self.cwd(), ) } @@ -176,7 +181,7 @@ impl SessionConfiguration { approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), - cwd: self.cwd.clone(), + environments: self.environments.clone(), workspace_roots: self.workspace_roots.clone(), profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, @@ -199,11 +204,11 @@ impl SessionConfiguration { let legacy_file_system_projection = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( ¤t_sandbox_policy, - &self.cwd, + self.cwd(), ¤t_file_system_sandbox_policy, ); let file_system_policy_matches_legacy = current_file_system_sandbox_policy - .is_semantically_equivalent_to(&legacy_file_system_projection, &self.cwd); + .is_semantically_equivalent_to(&legacy_file_system_projection, self.cwd()); let file_system_policy_has_rebindable_project_root_write = current_file_system_sandbox_policy .entries @@ -249,18 +254,21 @@ impl SessionConfiguration { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let absolute_cwd = updates.cwd.clone().unwrap_or_else(|| self.cwd.clone()); - - let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); - next_configuration.cwd = absolute_cwd; + let current_cwd = self.cwd().clone(); + let next_environments = updates + .environments + .clone() + .unwrap_or_else(|| self.environments.clone()); + let cwd_changed = next_environments.legacy_fallback_cwd.as_path() != current_cwd.as_path(); + next_configuration.environments = next_environments; if let Some(workspace_roots) = updates.workspace_roots.clone() { next_configuration.workspace_roots = workspace_roots; - } else if cwd_changed && self.workspace_roots.contains(&self.cwd) { + } else if cwd_changed && self.workspace_roots.contains(¤t_cwd) { let mut retargeted_workspace_roots = Vec::with_capacity(next_configuration.workspace_roots.len()); for root in &self.workspace_roots { - let root = if root == &self.cwd { - next_configuration.cwd.clone() + let root = if root == ¤t_cwd { + next_configuration.cwd().clone() } else { root.clone() }; @@ -317,7 +325,7 @@ impl SessionConfiguration { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( &sandbox_policy, - &next_configuration.cwd, + next_configuration.cwd(), ¤t_file_system_sandbox_policy, ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); @@ -340,7 +348,7 @@ impl SessionConfiguration { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( ¤t_sandbox_policy, - &next_configuration.cwd, + next_configuration.cwd(), ¤t_file_system_sandbox_policy, ); next_configuration @@ -401,7 +409,7 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { - pub(crate) cwd: Option, + pub(crate) environments: Option, pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, @@ -414,10 +422,6 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) reasoning_summary: Option, pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, - /// Turn-local environment override. `None` inherits the sticky thread - /// environments stored on `SessionConfiguration`; `Some([])` explicitly - /// disables environments for this turn. - pub(crate) environments: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, @@ -634,7 +638,7 @@ impl Session { Arc::clone(&environment_manager), Arc::clone(&plugins_manager), Arc::clone(&skills_manager), - session_configuration.environments.clone(), + session_configuration.environment_selections().to_vec(), ) .instrument(info_span!( "session_init.plugin_skill_warmup", @@ -686,7 +690,7 @@ impl Session { nickname: session_configuration.session_source.get_nickname(), agent_role: session_configuration.session_source.get_agent_role(), session_source: session_configuration.session_source.clone(), - cwd: session_configuration.cwd.to_path_buf(), + cwd: session_configuration.cwd().to_path_buf(), rollout_path: rollout_path.clone(), model: session_configuration.collaboration_mode.model().to_string(), provider_name: config.model_provider_id.clone(), @@ -790,7 +794,7 @@ impl Session { /*inc*/ 1, &[( "is_git", - if get_git_repo_root(&session_configuration.cwd).is_some() { + if get_git_repo_root(session_configuration.cwd()).is_some() { "true" } else { "false" @@ -809,7 +813,7 @@ impl Session { config.permissions.approval_policy.value(), config .permissions - .legacy_sandbox_policy(session_configuration.cwd.as_path()), + .legacy_sandbox_policy(session_configuration.cwd().as_path()), mcp_servers.keys().map(String::as_str).collect(), ); @@ -844,7 +848,7 @@ impl Session { ShellSnapshot::start_snapshotting( config.codex_home.clone(), thread_id, - session_configuration.cwd.clone(), + session_configuration.cwd().clone(), &mut default_shell, session_telemetry.clone(), state_db_ctx.clone(), @@ -1089,7 +1093,7 @@ impl Session { approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), - cwd: session_configuration.cwd.clone(), + cwd: session_configuration.cwd().clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), initial_messages, network_proxy: session_network_proxy.filter(|_| { @@ -1135,7 +1139,7 @@ impl Session { } let turn_environment = crate::environment_selection::resolve_environment_selections( sess.services.environment_manager.as_ref(), - &session_configuration.environments, + session_configuration.environment_selections(), ) .map_err(|err| { CodexErr::InvalidRequest(err.to_string().replace( @@ -1152,7 +1156,7 @@ impl Session { ), None => McpRuntimeContext::new( Arc::clone(&sess.services.environment_manager), - session_configuration.cwd.to_path_buf(), + session_configuration.cwd().to_path_buf(), ), }; let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4d914ff985..d44c7fbdcc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -22,6 +22,7 @@ use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::loader::project_trust_key; use codex_config::types::ToolSuggestDisabledTool; +use core_test_support::test_codex::local_selections; use codex_features::Feature; use codex_login::CodexAuth; @@ -53,6 +54,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; @@ -142,6 +144,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; use core_test_support::tracing::install_test_tracing; @@ -2392,7 +2395,8 @@ async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow: ); let mut session_configuration = make_session_configuration_for_tests().await; - session_configuration.cwd = config.cwd.clone(); + session_configuration.environments = + TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()); session_configuration.workspace_roots = config.workspace_roots.clone(); session_configuration.permission_profile_state = session_permission_profile_state; @@ -2404,8 +2408,8 @@ async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow: ..Default::default() })?; let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); Ok(()) } @@ -2437,7 +2441,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "fork seed".into(), text_elements: Vec::new(), @@ -2483,7 +2486,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< forked .thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -3157,11 +3159,10 @@ async fn set_rate_limits_retains_previous_credits() { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3265,11 +3266,10 @@ async fn set_rate_limits_updates_plan_type_when_present() { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3797,11 +3797,10 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3920,7 +3919,8 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd let project_root = project_root.abs(); let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd.abs(); + session_configuration.environments = + TurnEnvironmentSelections::new(original_cwd.abs(), Vec::new()); let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -3954,7 +3954,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root), + environments: Some(TurnEnvironmentSelections::new(project_root, Vec::new())), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -3969,7 +3969,8 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd async fn session_configuration_apply_permission_profile_preserves_existing_deny_read_entries() { let mut session_configuration = make_session_configuration_for_tests().await; let cwd = tempfile::tempdir().expect("create temp dir"); - session_configuration.cwd = cwd.path().abs(); + session_configuration.environments = + TurnEnvironmentSelections::new(cwd.path().abs(), Vec::new()); let workspace_policy = SandboxPolicy::new_workspace_write_policy(); let deny_entry = FileSystemSandboxEntry { @@ -3981,7 +3982,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ let mut existing_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &workspace_policy, - session_configuration.cwd.as_path(), + session_configuration.cwd().as_path(), ); existing_file_system_policy.glob_scan_max_depth = Some(2); existing_file_system_policy.entries.push(deny_entry.clone()); @@ -3997,7 +3998,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &workspace_policy, - session_configuration.cwd.as_path(), + session_configuration.cwd().as_path(), ); let permission_profile = codex_protocol::models::PermissionProfile::from_runtime_permissions( &requested_file_system_policy, @@ -4024,7 +4025,8 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ async fn session_configuration_apply_permission_profile_accepts_direct_write_roots() { let mut session_configuration = make_session_configuration_for_tests().await; let cwd = tempfile::tempdir().expect("create cwd"); - session_configuration.cwd = cwd.path().abs(); + session_configuration.environments = + TurnEnvironmentSelections::new(cwd.path().abs(), Vec::new()); let external_write_dir = tempfile::tempdir().expect("create external write root"); let external_write_path = AbsolutePathBuf::from_absolute_path( codex_utils_absolute_path::canonicalize_preserving_symlinks(external_write_dir.path()) @@ -4100,8 +4102,8 @@ async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspa .expect("permission profile update should succeed"); let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); assert_eq!( updated.active_permission_profile(), Some(ActivePermissionProfile::new("dev")) @@ -4118,7 +4120,8 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let old_root = old_root.path().abs(); let new_root = new_root.path().abs(); let extra_root = extra_root.path().abs(); - session_configuration.cwd = old_root.clone(); + session_configuration.environments = + TurnEnvironmentSelections::new(old_root.clone(), Vec::new()); session_configuration.workspace_roots = vec![old_root.clone(), extra_root.clone()]; let file_system_sandbox_policy = @@ -4138,7 +4141,7 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(new_root.clone()), + environments: Some(TurnEnvironmentSelections::new(new_root.clone(), Vec::new())), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4148,9 +4151,9 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up vec![new_root.clone(), extra_root.clone()] ); let updated_policy = updated.file_system_sandbox_policy(); - assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); - assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd.as_path())); - assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd().as_path())); + assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd().as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd().as_path())); } #[tokio::test] @@ -4353,8 +4356,9 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let workspace = tempfile::tempdir().expect("create temp dir"); let original_cwd = workspace.path().join("repo-a").abs(); let project_root = workspace.path().join("repo-b").abs(); - session_configuration.cwd = original_cwd.clone(); - session_configuration.workspace_roots = vec![session_configuration.cwd.clone()]; + session_configuration.environments = + TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); + session_configuration.workspace_roots = vec![session_configuration.cwd().clone()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -4363,7 +4367,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda }; let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &sandbox_policy, - &session_configuration.cwd, + session_configuration.cwd(), ); session_configuration .set_permission_profile_for_tests( @@ -4377,7 +4381,10 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.clone()), + environments: Some(TurnEnvironmentSelections::new( + project_root.clone(), + Vec::new(), + )), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4386,13 +4393,13 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda assert!( updated .file_system_sandbox_policy() - .can_write_path_with_cwd(project_root.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(project_root.as_path(), updated.cwd().as_path()), "cwd-only update should keep the new cwd writable" ); assert!( !updated .file_system_sandbox_policy() - .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), "cwd-only update should not keep the old implicit cwd writable" ); } @@ -4408,7 +4415,8 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up let original_cwd = original_cwd.abs(); let next_cwd = next_cwd.abs(); - session_configuration.cwd = original_cwd.clone(); + session_configuration.environments = + TurnEnvironmentSelections::new(original_cwd.clone(), Vec::new()); let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -4435,7 +4443,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(next_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new(next_cwd.clone(), Vec::new())), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4447,13 +4455,13 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up assert!( updated .file_system_sandbox_policy() - .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), "absolute grant to the old cwd must remain writable" ); assert!( !updated .file_system_sandbox_policy() - .can_write_path_with_cwd(next_cwd.as_path(), updated.cwd.as_path()), + .can_write_path_with_cwd(next_cwd.as_path(), updated.cwd().as_path()), "cwd-only update must not reinterpret an absolute old-cwd grant as :workspace_roots" ); } @@ -4463,11 +4471,21 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let (session, turn_context) = make_session_and_context().await; #[allow(deprecated)] let updated_cwd = turn_context.cwd.join("project"); + let current_environments = { + let state = session.state.lock().await; + state + .session_configuration + .environment_selections() + .to_vec() + }; std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session .update_settings(SessionSettingsUpdate { - cwd: Some(updated_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new( + updated_cwd.clone(), + current_environments, + )), ..Default::default() }) .await @@ -4475,7 +4493,7 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let session_cwd = { let state = session.state.lock().await; - state.session_configuration.cwd.clone() + state.session_configuration.cwd().clone() }; let config = session.get_config().await; let next_turn = session.new_default_turn().await; @@ -4495,55 +4513,65 @@ async fn relative_cwd_update_without_environments_resolves_under_session_cwd() { let (session, _turn_context) = make_session_and_context().await; let original_cwd = { let mut state = session.state.lock().await; - state.session_configuration.environments = Vec::new(); - state.session_configuration.cwd.clone() + state.session_configuration.environments.environments = Vec::new(); + state.session_configuration.cwd().clone() }; let updated_cwd = original_cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session .update_settings(SessionSettingsUpdate { - cwd: Some(updated_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new( + updated_cwd.clone(), + Vec::new(), + )), ..Default::default() }) .await .expect("cwd update should succeed"); let state = session.state.lock().await; - assert_eq!(state.session_configuration.cwd, updated_cwd); - assert!(state.session_configuration.environments.is_empty()); + assert_eq!(state.session_configuration.cwd(), &updated_cwd); + assert!( + state + .session_configuration + .environment_selections() + .is_empty() + ); } #[tokio::test] -async fn cwd_update_does_not_rewrite_sticky_environment_cwd() { +async fn cwd_update_rewrites_sticky_environment_cwd() { let (session, _turn_context) = make_session_and_context().await; - let (original_cwd, environment_cwd) = { + let (original_cwd, environment_cwd, environments) = { let mut state = session.state.lock().await; - let original_cwd = state.session_configuration.cwd.clone(); + let original_cwd = state.session_configuration.cwd().clone(); let environment_cwd = original_cwd.join("environment"); - state.session_configuration.environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: environment_cwd.clone(), - }]; - (original_cwd, environment_cwd) + let environments = vec![local(environment_cwd.clone())]; + state.session_configuration.environments.environments = environments.clone(); + (original_cwd, environment_cwd, environments) }; let updated_cwd = original_cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session .update_settings(SessionSettingsUpdate { - cwd: Some(updated_cwd.clone()), + environments: Some(TurnEnvironmentSelections::new( + updated_cwd.clone(), + environments, + )), ..Default::default() }) .await .expect("cwd update should succeed"); let state = session.state.lock().await; - assert_eq!(state.session_configuration.cwd, updated_cwd); + assert_eq!(state.session_configuration.cwd(), &updated_cwd); assert_eq!( - state.session_configuration.environments[0].cwd, - environment_cwd + state.session_configuration.environment_selections()[0].cwd, + updated_cwd ); + assert_ne!(environment_cwd, updated_cwd); } #[tokio::test] @@ -4551,7 +4579,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; let absolute_cwd = { let state = session.state.lock().await; - state.session_configuration.cwd.join("absolute-turn") + state.session_configuration.cwd().join("absolute-turn") }; std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir"); @@ -4559,11 +4587,10 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - cwd: Some(absolute_cwd.clone()), - environments: Some(vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: absolute_cwd.clone(), - }]), + environments: Some(TurnEnvironmentSelections::new( + absolute_cwd.clone(), + vec![local(absolute_cwd.clone())], + )), ..Default::default() }, ) @@ -4622,11 +4649,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), Vec::new()), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -4712,10 +4738,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4733,11 +4756,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -4751,7 +4773,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { user_shell_override: None, }; let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); + Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -4863,7 +4885,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) .await, ); - let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); + let turn_environments = turn_environments_for_tests(&environment, session_configuration.cwd()); let turn_context = Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -4880,7 +4902,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &models_manager, /*network*/ None, turn_environments, - session_configuration.cwd.clone(), + session_configuration.cwd().clone(), "turn_id".to_string(), skills_outcome, ); @@ -4948,10 +4970,7 @@ async fn make_session_with_config_and_rx( developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4969,11 +4988,10 @@ async fn make_session_with_config_and_rx( approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -5053,10 +5071,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -5074,11 +5089,10 @@ async fn make_session_with_history_source_and_agent_control_and_rx( approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -5937,7 +5951,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { fn op_kind_for_input_and_context_ops() { assert_eq!( Op::UserInput { - environments: None, items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -5969,12 +5982,11 @@ async fn user_turn_updates_approvals_reviewer() { text: "hello".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.clone()), + environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), sandbox_policy: Some(config.legacy_sandbox_policy()), @@ -6013,10 +6025,10 @@ async fn turn_environments_set_primary_environment() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: selected_cwd.clone(), - }]), + environments: Some(TurnEnvironmentSelections::new( + selected_cwd.clone(), + vec![local(selected_cwd.clone())], + )), ..Default::default() }, ) @@ -6041,7 +6053,7 @@ async fn turn_environments_set_primary_environment() { } #[tokio::test] -async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { +async fn default_turn_does_not_overlay_legacy_fallback_cwd_onto_stored_thread_environments() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; let session_cwd = session.get_config().await.cwd.clone(); let selected_cwd = @@ -6049,10 +6061,7 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { { let mut state = session.state.lock().await; - state.session_configuration.environments = vec![TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: selected_cwd.clone(), - }]; + state.session_configuration.environments.environments = vec![local(selected_cwd.clone())]; } let turn_context = session.new_default_turn().await; @@ -6069,8 +6078,8 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { )); #[allow(deprecated)] let turn_cwd = turn_context.cwd.clone(); - assert_eq!(turn_cwd, session_cwd); - assert_eq!(turn_context.config.cwd, session_cwd); + assert_eq!(turn_cwd, selected_cwd); + assert_eq!(turn_context.config.cwd, selected_cwd); } #[tokio::test] @@ -6080,7 +6089,7 @@ async fn default_turn_honors_empty_stored_thread_environments() { { let mut state = session.state.lock().await; - state.session_configuration.environments = Vec::new(); + state.session_configuration.environments.environments = Vec::new(); } let turn_context = session.new_default_turn().await; @@ -6143,7 +6152,10 @@ async fn empty_turn_environments_clear_primary_environment() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![]), + environments: Some(TurnEnvironmentSelections::new( + session.get_config().await.cwd.clone(), + vec![], + )), ..Default::default() }, ) @@ -6170,10 +6182,13 @@ async fn unknown_turn_environment_returns_error() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![TurnEnvironmentSelection { - environment_id: "missing".to_string(), - cwd: original_configuration.cwd.clone(), - }]), + environments: Some(TurnEnvironmentSelections::new( + original_configuration.cwd().clone(), + vec![TurnEnvironmentSelection { + environment_id: "missing".to_string(), + cwd: original_configuration.cwd().clone(), + }], + )), ..Default::default() }, ) @@ -6186,10 +6201,10 @@ async fn unknown_turn_environment_returns_error() { }; assert!(matches!(err, CodexErr::InvalidRequest(_))); assert!(err.to_string().contains("missing")); - assert_eq!(current_configuration.cwd, original_configuration.cwd); + assert_eq!(current_configuration.cwd(), original_configuration.cwd()); assert_eq!( - current_configuration.environments, - original_configuration.environments + current_configuration.environment_selections(), + original_configuration.environment_selections() ); } @@ -6205,16 +6220,13 @@ async fn duplicate_turn_environment_returns_error_without_mutating_session() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - environments: Some(vec![ - TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: original_configuration.cwd.clone(), - }, - TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: original_configuration.cwd.join("second"), - }, - ]), + environments: Some(TurnEnvironmentSelections::new( + original_configuration.cwd().clone(), + vec![ + local(original_configuration.cwd().clone()), + local(original_configuration.cwd().join("second")), + ], + )), ..Default::default() }, ) @@ -6227,10 +6239,10 @@ async fn duplicate_turn_environment_returns_error_without_mutating_session() { }; assert!(matches!(err, CodexErr::InvalidRequest(_))); assert!(err.to_string().contains("duplicate")); - assert_eq!(current_configuration.cwd, original_configuration.cwd); + assert_eq!(current_configuration.cwd(), original_configuration.cwd()); assert_eq!( - current_configuration.environments, - original_configuration.environments + current_configuration.environment_selections(), + original_configuration.environment_selections() ); } @@ -6789,10 +6801,7 @@ where developer_instructions: None, }, }; - let default_environments = vec![TurnEnvironmentSelection { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - cwd: config.cwd.clone(), - }]; + let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -6810,11 +6819,10 @@ where approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), + environments: TurnEnvironmentSelections::new(config.cwd.clone(), default_environments), workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environments: default_environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -6828,7 +6836,7 @@ where user_shell_override: None, }; let per_turn_config = - Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); + Session::build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); let model_info = construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -6940,7 +6948,7 @@ where .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) .await, ); - let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); + let turn_environments = turn_environments_for_tests(&environment, session_configuration.cwd()); let turn_context = Arc::new(Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -6957,7 +6965,7 @@ where &models_manager, /*network*/ None, turn_environments, - session_configuration.cwd.clone(), + session_configuration.cwd().clone(), "turn_id".to_string(), skills_outcome, )); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5f52912d59..be6e316a00 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -441,7 +441,7 @@ impl Session { session_configuration: &SessionConfiguration, ) -> Config { let mut config = - Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); + Self::build_per_turn_config(session_configuration, session_configuration.cwd().clone()); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); config.workspace_roots = session_configuration.workspace_roots.clone(); @@ -587,19 +587,9 @@ impl Session { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { Ok(next) => { - let mut effective_environments = updates - .environments - .clone() - .unwrap_or_else(|| next.environments.clone()); - if updates.environments.is_none() { - Self::overlay_runtime_cwd_on_primary_environment( - &mut effective_environments, - &next.cwd, - ); - } let turn_environments = - self.resolve_turn_environments(&effective_environments)?; - let previous_cwd = state.session_configuration.cwd.clone(); + self.resolve_turn_environments(next.environment_selections())?; + let previous_cwd = state.session_configuration.cwd().clone(); let previous_permission_profile = state.session_configuration.permission_profile(); let next_permission_profile = next.permission_profile(); @@ -656,7 +646,7 @@ impl Session { self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, - &session_configuration.cwd, + session_configuration.cwd(), &codex_home, &session_source, ); @@ -731,7 +721,7 @@ impl Session { let cwd = primary_turn_environment .as_ref() .map(|turn_environment| turn_environment.cwd.clone()) - .unwrap_or_else(|| session_configuration.cwd.clone()); + .unwrap_or_else(|| session_configuration.cwd().clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { let mcp_connection_manager = self.services.mcp_connection_manager.read().await; @@ -871,29 +861,14 @@ impl Session { let state = self.state.lock().await; state.session_configuration.clone() }; - let mut effective_environments = session_configuration.environments.clone(); - Self::overlay_runtime_cwd_on_primary_environment( - &mut effective_environments, - &session_configuration.cwd, - ); - let turn_environments = match self.resolve_turn_environments(&effective_environments) { - Ok(turn_environments) => turn_environments, - Err(err) => { - warn!("failed to resolve stored session environments: {err}"); - ResolvedTurnEnvironments::default() - } - }; + let turn_environments = + match self.resolve_turn_environments(session_configuration.environment_selections()) { + Ok(turn_environments) => turn_environments, + Err(err) => { + warn!("failed to resolve stored session environments: {err}"); + ResolvedTurnEnvironments::default() + } + }; (session_configuration, turn_environments) } - - fn overlay_runtime_cwd_on_primary_environment( - environments: &mut [TurnEnvironmentSelection], - runtime_cwd: &AbsolutePathBuf, - ) { - if let Some(turn_environment) = environments.first_mut() - && turn_environment.cwd != *runtime_cwd - { - turn_environment.cwd = runtime_cwd.clone(); - } - } } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 34ec682110..116473258e 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -21,7 +21,6 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; -use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::responses::mount_models_once; @@ -343,99 +342,6 @@ async fn start_thread_rejects_explicit_local_environment_when_default_provider_i assert!(manager.list_thread_ids().await.is_empty()); } -#[tokio::test] -async fn start_thread_uses_all_default_environments_from_codex_home() { - let temp_dir = tempdir().expect("tempdir"); - let mut config = test_config().await; - config.codex_home = temp_dir.path().join("codex-home").abs(); - config.cwd = config.codex_home.abs(); - std::fs::create_dir_all(&config.codex_home).expect("create codex home"); - std::fs::write( - config.codex_home.join("environments.toml"), - r#" -default = "dev" - -[[environments]] -id = "dev" -program = "ssh" -args = ["dev", "cd /tmp && true"] -"#, - ) - .expect("write environments.toml"); - - let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe path"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths"); - let environment_manager = Arc::new( - codex_exec_server::EnvironmentManager::from_codex_home( - config.codex_home.clone(), - Some(runtime_paths), - ) - .await - .expect("environment manager"), - ); - assert_eq!( - environment_manager.default_environment_ids(), - vec!["dev".to_string(), "local".to_string()] - ); - - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.to_path_buf(), - environment_manager, - ); - - let thread = manager - .start_thread(config) - .await - .expect("thread should start"); - - let prompt_items = crate::prompt_debug::build_prompt_input_from_session( - thread.thread.codex.session.as_ref(), - Vec::::new(), - ) - .await - .expect("prompt input"); - let environment_context = prompt_items - .iter() - .filter_map(|item| match item { - ResponseItem::Message { content, .. } => Some(content), - _ => None, - }) - .flatten() - .find_map(|content| match content { - ContentItem::InputText { text } if text.contains("") => { - Some(text.as_str()) - } - _ => None, - }) - .expect("environment context prompt item"); - assert!(environment_context.contains("")); - let cwd = thread.session_configured.cwd.display().to_string(); - let dev_entry = format!( - r#" - {cwd} - "# - ); - let local_entry = format!( - r#" - {cwd} - "# - ); - let dev_position = environment_context - .find(&dev_entry) - .expect("dev environment entry"); - let local_position = environment_context - .find(&local_entry) - .expect("local environment entry"); - assert!(dev_position < local_position); - assert!(!environment_context.contains("\n ")); - assert!(!environment_context.contains("\n ")); -} - #[tokio::test] async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { let temp_dir = tempdir().expect("tempdir"); 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 ac78113a30..f55d9c0876 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2676,7 +2676,6 @@ async fn send_input_accepts_structured_items() { .expect("send_input should succeed"); let expected = Op::UserInput { - environments: None, items: vec![ UserInput::Mention { name: "drive".to_string(), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 1e43135900..62f422ba1c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -40,6 +40,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; @@ -70,6 +71,17 @@ const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL" static REMOTE_TEST_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); const SUBMIT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); +pub fn local(cwd: AbsolutePathBuf) -> TurnEnvironmentSelection { + TurnEnvironmentSelection { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + cwd, + } +} + +pub fn local_selections(cwd: AbsolutePathBuf) -> TurnEnvironmentSelections { + TurnEnvironmentSelections::new(cwd.clone(), vec![local(cwd)]) +} + #[derive(Debug)] pub struct TestEnv { environment: codex_exec_server::Environment, @@ -789,18 +801,20 @@ impl TestCodex { let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, self.config.cwd.as_path()); let session_model = self.session_configured.model.clone(); + let turn_environment_selections = environments.map(|environments| { + TurnEnvironmentSelections::new(self.config.cwd.clone(), environments) + }); self.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], - environments, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(self.config.cwd.clone()), + environments: turn_environment_selections, approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 82bb8c8798..d44b4e5a23 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -46,7 +46,6 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { // Kick off a turn that triggers the function call. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start sleep".into(), text_elements: Vec::new(), @@ -104,7 +103,6 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start history recording".into(), text_elements: Vec::new(), @@ -126,7 +124,6 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), @@ -210,7 +207,6 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start interrupt marker".into(), text_elements: Vec::new(), @@ -232,7 +228,6 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/additional_context.rs b/codex-rs/core/tests/suite/additional_context.rs index f9bfdb966b..6812be95e4 100644 --- a/codex-rs/core/tests/suite/additional_context.rs +++ b/codex-rs/core/tests/suite/additional_context.rs @@ -37,7 +37,6 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "inspect the active tab".to_string(), text_elements: Vec::new(), @@ -136,7 +135,6 @@ async fn external_context_like_user_text_remains_a_user_message_item() -> Result test.codex .submit(Op::UserInput { - environments: None, items: vec![user_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -182,7 +180,6 @@ async fn additional_context_trust_controls_message_role() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "inspect context".to_string(), text_elements: Vec::new(), @@ -263,7 +260,6 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -281,7 +277,6 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -343,7 +338,6 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -376,7 +370,6 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -409,7 +402,6 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "third turn".to_string(), text_elements: Vec::new(), @@ -506,7 +498,6 @@ async fn additional_context_values_are_truncated_before_model_input() -> Result< test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "summarize context".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index db301e39b4..a9ebadcebc 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -7,6 +7,7 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; +use core_test_support::test_codex::local_selections; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -52,6 +53,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -100,12 +102,11 @@ async fn submit_without_wait_with_turn_permissions( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(harness.cwd_abs()), + environments: Some(local_selections(harness.cwd_abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -1631,27 +1632,27 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + let environments = vec![ + local(shared_cwd.clone()), + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + ]; test.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: "apply matching patches to local and remote environments".into(), text_elements: Vec::new(), }], - environments: Some(vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: shared_cwd.clone(), - }, - TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: shared_cwd.clone(), - }, - ]), final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( + test.config.cwd.clone(), + environments, + )), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 865aabb46c..927b1f18f9 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -39,6 +39,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -658,12 +659,11 @@ async fn submit_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2619,12 +2619,11 @@ async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork( text: "run nested env zsh script through python".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2764,12 +2763,11 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { text: "run allowed touch under zsh fork".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -3356,12 +3354,11 @@ allow_local_binding = true text: "deny-read network retry".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 5de8f421b9..5fe1cbc2f2 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -34,6 +34,7 @@ use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -160,12 +161,11 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re text: "run the Guardian model override check".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2fcb73e25a..9a6f104d0c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -66,6 +66,7 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_failed; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use dunce::canonicalize as normalize_path; @@ -386,7 +387,6 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // 2) Submit new input; the request body must include the prior items, then initial context, then new user input. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -755,7 +755,6 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -968,7 +967,6 @@ async fn includes_base_instructions_override_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1026,7 +1024,6 @@ async fn chatgpt_auth_sends_correct_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1151,7 +1148,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1191,7 +1187,6 @@ async fn includes_user_instructions_message_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1280,7 +1275,6 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1344,7 +1338,6 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1404,7 +1397,6 @@ async fn omits_apps_guidance_when_configured_off() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1447,7 +1439,6 @@ async fn omits_environment_context_when_configured_off() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1505,7 +1496,6 @@ async fn skills_append_to_developer_message() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1589,7 +1579,6 @@ async fn skills_use_aliases_in_developer_message_under_budget_pressure() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1651,7 +1640,6 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1694,7 +1682,6 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1738,7 +1725,6 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1794,12 +1780,11 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.clone()), + environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some( @@ -1847,7 +1832,6 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1904,7 +1888,6 @@ async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1972,12 +1955,11 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.clone()), + environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some(ReasoningSummary::Concise), @@ -2029,7 +2011,6 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2089,7 +2070,6 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2129,7 +2109,6 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2178,7 +2157,6 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2226,7 +2204,6 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2279,7 +2256,6 @@ async fn includes_developer_instructions_message_in_request() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2577,7 +2553,6 @@ async fn token_count_includes_rate_limits_snapshot() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2719,7 +2694,6 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { let submission_id = codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2797,7 +2771,6 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "seed turn".into(), text_elements: Vec::new(), @@ -2813,7 +2786,6 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "trigger context window".into(), text_elements: Vec::new(), @@ -2899,7 +2871,6 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res .await?; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "trigger incomplete".into(), text_elements: Vec::new(), @@ -3011,7 +2982,6 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -3101,7 +3071,6 @@ async fn env_var_overrides_loaded_auth() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -3159,7 +3128,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 1: user sends U1; wait for completion. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "U1".into(), text_elements: Vec::new(), @@ -3176,7 +3144,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 2: user sends U2; wait for completion. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "U2".into(), text_elements: Vec::new(), @@ -3193,7 +3160,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 3: user sends U3; wait for completion. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "U3".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 3e1ed23261..4dc674ec30 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1231,7 +1231,6 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { let submission_id = test .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1322,7 +1321,6 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { let submission_id = test .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index f5f17a09bd..e1b02292ef 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3199,12 +3199,14 @@ text( text: "use exec to inspect and call hidden tools".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( + cwd, + Vec::new(), + )), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 0d4f4cf6f8..d9d511edbd 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -13,6 +13,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -72,7 +73,6 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -127,7 +127,6 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -169,12 +168,11 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -220,12 +218,11 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -276,7 +273,6 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -329,12 +325,11 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -390,7 +385,6 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -414,7 +408,6 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -467,7 +460,6 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -491,7 +483,6 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -546,7 +537,6 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -573,7 +563,6 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -629,7 +618,6 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -656,7 +644,6 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -715,7 +702,6 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -732,7 +718,6 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -785,7 +770,6 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5fe90bf7a7..ac497aff3b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -31,6 +31,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::test_path_buf; @@ -96,12 +97,11 @@ fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: S text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -404,7 +404,6 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), @@ -430,7 +429,6 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: THIRD_USER_MSG.into(), text_elements: Vec::new(), @@ -606,7 +604,6 @@ async fn manual_pre_compact_block_decision_does_not_block_compaction() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello before blocked compact".to_string(), text_elements: Vec::new(), @@ -680,7 +677,6 @@ async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello before matched compact".to_string(), text_elements: Vec::new(), @@ -751,7 +747,6 @@ async fn manual_compact_uses_custom_prompt() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -899,7 +894,6 @@ async fn manual_compact_emits_context_compaction_items() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "manual compact".into(), text_elements: Vec::new(), @@ -1066,7 +1060,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // Start the conversation with the user message codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user_message.into(), text_elements: Vec::new(), @@ -1539,7 +1532,6 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1556,7 +1548,6 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1573,7 +1564,6 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1745,7 +1735,6 @@ async fn auto_compact_emits_context_compaction_items() { for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -1827,7 +1816,6 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1843,7 +1831,6 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1859,7 +1846,6 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2388,7 +2374,6 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2404,7 +2389,6 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2420,7 +2404,6 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2510,7 +2493,6 @@ async fn manual_compact_retries_after_context_window_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2615,7 +2597,6 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2711,7 +2692,6 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2730,7 +2710,6 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2749,7 +2728,6 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: final_user_message.into(), text_elements: Vec::new(), @@ -2961,7 +2939,6 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3067,7 +3044,6 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), text_elements: Vec::new(), @@ -3499,7 +3475,6 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3619,7 +3594,6 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { for user in [first_user, second_user, third_user] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3682,7 +3656,6 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess for user in ["USER_ONE", "USER_TWO"] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3699,7 +3672,9 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), + environments: Some(local_selections( + test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs(), + )), ..Default::default() }, ) @@ -3709,7 +3684,6 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .to_string(); codex .submit(Op::UserInput { - environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -3905,7 +3879,6 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3921,7 +3894,6 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3995,7 +3967,6 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 12fd570c56..73a500f04d 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,5 +1,6 @@ #![allow(clippy::expect_used)] +use core_test_support::test_codex::local_selections; use std::fs; use anyhow::Result; @@ -324,7 +325,6 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -342,7 +342,6 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -585,7 +584,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "TURN_ONE_USER".to_string(), text_elements: Vec::new(), @@ -600,7 +598,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![ UserInput::Text { text: "TURN_TWO_PREFIX".to_string(), @@ -621,7 +618,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "TURN_THREE_TOOL_USER".to_string(), text_elements: Vec::new(), @@ -636,7 +632,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![ UserInput::Image { image_url, @@ -657,7 +652,6 @@ async fn assert_remote_manual_compact_request_parity( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "TURN_FIVE_USER".to_string(), text_elements: Vec::new(), @@ -826,7 +820,6 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -844,7 +837,6 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -976,7 +968,6 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -994,7 +985,6 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -1081,7 +1071,6 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -1099,7 +1088,6 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -1187,7 +1175,6 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -1260,7 +1247,6 @@ async fn remote_compact_runs_automatically() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -1398,7 +1384,6 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -1413,7 +1398,6 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1528,7 +1512,6 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -1543,7 +1526,6 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1656,7 +1638,6 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -1671,7 +1652,6 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1692,7 +1672,6 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -1816,7 +1795,6 @@ async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Resul codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Find the oversized deferred tool".to_string(), text_elements: Vec::new(), @@ -1897,7 +1875,6 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "turn that exceeds token threshold".into(), text_elements: Vec::new(), @@ -1912,7 +1889,6 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -2007,7 +1983,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2025,7 +2000,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2117,7 +2091,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2135,7 +2108,6 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2212,7 +2184,6 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -2294,7 +2265,6 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -2379,7 +2349,6 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "needs compaction".into(), text_elements: Vec::new(), @@ -2523,7 +2492,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -2542,7 +2510,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -2568,7 +2535,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -2665,7 +2631,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -2683,7 +2648,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -2756,7 +2720,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2771,7 +2734,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2839,7 +2801,6 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2901,7 +2862,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2918,7 +2878,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2994,7 +2953,6 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3012,7 +2970,6 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3096,7 +3053,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "SETUP_USER".to_string(), text_elements: Vec::new(), @@ -3113,7 +3069,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3205,7 +3160,6 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3236,7 +3190,6 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3318,7 +3271,9 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), + environments: Some(local_selections( + test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs(), + )), ..Default::default() }, ) @@ -3326,7 +3281,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us } codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3414,7 +3368,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -3437,7 +3390,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -3557,7 +3509,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3572,7 +3523,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3658,7 +3608,6 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3737,7 +3686,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3824,7 +3772,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3842,7 +3789,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3925,7 +3871,6 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 395f3158dd..1b7c50936c 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -605,7 +605,6 @@ async fn capture_from_requests( async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec) -> Result<()> { codex .submit(Op::UserInput { - environments: None, items, final_output_json_schema: None, responsesapi_client_metadata: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 47d1d1bb58..06ef8f62a1 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -34,6 +34,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -551,7 +552,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { core_test_support::submit_thread_settings( &conversation, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(override_cwd.clone()), + environments: Some(local_selections(override_cwd.clone())), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -774,7 +775,6 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 25292149a0..e0d198771d 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -17,6 +17,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -51,12 +52,11 @@ async fn submit_user_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -210,12 +210,11 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { text: "run shell command".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index a1eb7eba32..b4898ea827 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -50,7 +50,6 @@ async fn fork_thread_twice_drops_to_first_message() { for text in ["first", "second", "third"] { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -173,7 +172,6 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "fork me from stored history".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 32808dc434..ae87350c39 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -19,6 +19,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -111,7 +112,6 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "run a command that requires Guardian review".into(), text_elements: Vec::new(), @@ -120,7 +120,7 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 5813508e7a..16412c2d27 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1843,7 +1843,6 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "initial prompt".to_string(), text_elements: Vec::new(), @@ -1863,7 +1862,6 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu for text in ["accepted queued prompt", "blocked queued prompt"] { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index bcfa0b7392..199a7d1db6 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -18,6 +18,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -125,12 +126,11 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu text_elements: Vec::new(), }, ], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -224,12 +224,11 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> text_elements: Vec::new(), }, ], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a0010b6ffe..a39a2b5e05 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -35,6 +35,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -56,12 +57,11 @@ fn disabled_plan_turn( text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -117,7 +117,6 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -174,7 +173,6 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "please summarize results".into(), text_elements: Vec::new(), @@ -236,7 +234,6 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "explain your reasoning".into(), text_elements: Vec::new(), @@ -299,7 +296,6 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "find the weather".into(), text_elements: Vec::new(), @@ -380,7 +376,6 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "generate a tiny blue square".into(), text_elements: Vec::new(), @@ -468,7 +463,6 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -525,7 +519,6 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "please stream text".into(), text_elements: Vec::new(), @@ -1110,7 +1103,6 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "reason through it".into(), text_elements: Vec::new(), @@ -1166,7 +1158,6 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "show raw reasoning".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index e65d290b87..c9a855e6af 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -8,6 +8,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -81,12 +82,11 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { text: "hello world".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index b0c1910d60..49d3c0f2a6 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -32,6 +32,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; @@ -73,12 +74,11 @@ async fn submit_user_turn( text: text.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 15ed519af7..38c95394ea 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -127,7 +127,6 @@ async fn response_body_for_remote_model( text: "list tools".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -277,7 +276,6 @@ async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> text: ROOT_PROMPT.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b0d4bb558d..d176b5bc24 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -32,6 +32,7 @@ use core_test_support::responses::sse_completed; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -45,12 +46,11 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - turn_permission_fields(PermissionProfile::read_only(), test.cwd_path()); Op::UserInput { items, - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 25dd52174d..1623462182 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -1,5 +1,6 @@ #![allow(clippy::expect_used)] +use core_test_support::test_codex::local_selections; use std::fs; use std::sync::Arc; @@ -124,12 +125,11 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { text: "first turn".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(first_turn_cwd), + environments: Some(local_selections(first_turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(first_sandbox_policy), permission_profile: first_permission_profile, @@ -160,12 +160,11 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { text: "second turn with context updates".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(preturn_context_diff_cwd), + environments: Some(local_selections(preturn_context_diff_cwd)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(second_sandbox_policy), permission_profile: second_permission_profile, @@ -252,12 +251,11 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R text: "first turn in agents_one".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_one.clone()), + environments: Some(local_selections(cwd_one.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(first_sandbox_policy), permission_profile: first_permission_profile, @@ -286,12 +284,11 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R text: "second turn in agents_two".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_two), + environments: Some(local_selections(cwd_two)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(second_sandbox_policy), permission_profile: second_permission_profile, @@ -366,7 +363,6 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul .await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -413,12 +409,11 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text: "resume and change personality".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(resume_override_cwd), + environments: Some(local_selections(resume_override_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -483,7 +478,6 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - .await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -517,7 +511,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - core_test_support::submit_thread_settings( &resumed.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(resume_override_cwd), + environments: Some(local_selections(resume_override_cwd)), model: Some("gpt-5.2".to_string()), ..Default::default() }, @@ -526,7 +520,6 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first resumed turn after model override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 485a51346d..b343c4bbaf 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -1,3 +1,4 @@ +use core_test_support::test_codex::local_selections; use std::path::Path; use std::sync::Arc; @@ -97,12 +98,11 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { text: "hi".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 11935ca27f..8b2e9f35b1 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use core_test_support::test_codex::local_selections; use std::sync::Arc; use std::time::Duration; @@ -110,12 +111,11 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch text: "please run a tool".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index d6c7577a7f..95811d3f63 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -120,7 +120,6 @@ async fn responses_api_emits_api_request_event() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -167,7 +166,6 @@ async fn process_sse_emits_tracing_for_output_item() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -214,7 +212,6 @@ async fn process_sse_emits_failed_event_on_parse_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -262,7 +259,6 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -330,7 +326,6 @@ async fn process_sse_failed_event_records_response_error_message() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -396,7 +391,6 @@ async fn process_sse_failed_event_logs_parse_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -449,7 +443,6 @@ async fn process_sse_failed_event_logs_missing_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -511,7 +504,6 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -567,7 +559,6 @@ async fn process_sse_emits_completed_telemetry() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -647,7 +638,6 @@ async fn turn_and_completed_response_spans_record_token_usage() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -736,7 +726,6 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -831,7 +820,6 @@ async fn record_responses_sets_span_fields_for_response_events() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -921,7 +909,6 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -998,7 +985,6 @@ async fn handle_response_item_records_tool_result_for_function_call() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1076,7 +1062,6 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1250,7 +1235,6 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1306,7 +1290,6 @@ async fn handle_shell_command_user_approved_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "approved".into(), text_elements: Vec::new(), @@ -1377,7 +1360,6 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1448,7 +1430,6 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "retry".into(), text_elements: Vec::new(), @@ -1519,7 +1500,6 @@ async fn handle_shell_command_user_denies_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), @@ -1590,7 +1570,6 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1662,7 +1641,6 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index e42703dff8..beb553e4ea 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Op; use core_test_support::TempDirExt; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use tempfile::TempDir; @@ -68,7 +69,7 @@ async fn thread_settings_update_without_user_turn_does_not_record_environment_up core_test_support::submit_thread_settings( &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.abs()), + environments: Some(local_selections(new_cwd.abs())), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 8a6443b6ce..55a00a2110 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -1,3 +1,4 @@ +use core_test_support::test_codex::local_selections; use std::sync::Arc; use codex_core::CodexThread; @@ -96,7 +97,6 @@ async fn build_codex(server: &StreamingSseServer) -> Arc { async fn submit_user_input(codex: &CodexThread, text: &str) { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -119,12 +119,11 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { text: text.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -288,7 +287,6 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first prompt".into(), text_elements: Vec::new(), @@ -308,7 +306,6 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "second prompt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 38c0fe0911..e5a69962bc 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -51,7 +51,6 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -92,7 +91,6 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -116,7 +114,6 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -163,7 +160,6 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -178,7 +174,6 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -225,7 +220,6 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -249,7 +243,6 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -309,7 +302,6 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -334,7 +326,6 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -351,7 +342,6 @@ async fn resume_replays_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -412,7 +402,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -437,7 +426,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -460,7 +448,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -496,7 +483,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { forked .thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -558,7 +544,6 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 4bd335bb35..06c2fc41f9 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -26,6 +26,7 @@ use core_test_support::responses::sse_completed; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -65,12 +66,11 @@ fn read_only_text_turn_with_personality( text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 03b1afdfaa..412b5edbbc 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -180,7 +180,6 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() codex .submit(Op::UserInput { - environments: None, items: vec![codex_protocol::user_input::UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -261,7 +260,6 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), @@ -345,7 +343,6 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 640c1c1db1..80dee582ad 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -25,6 +25,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -153,7 +154,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -168,7 +168,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -251,7 +250,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -266,7 +264,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -334,7 +331,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -349,7 +345,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -435,7 +430,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // First turn codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -474,7 +468,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // Second turn after overrides codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -572,7 +565,6 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -729,7 +721,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // First turn codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -759,12 +750,11 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text: "hello 2".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.abs()), + environments: Some(local_selections(new_cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -876,12 +866,11 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 1".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -905,12 +894,11 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 2".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1019,12 +1007,11 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 1".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1050,12 +1037,11 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 2".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.clone()), + environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 904c116cfb..33f7d77fa7 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -41,7 +41,6 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "quota?".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index a2aec5c95f..1c6763aeba 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2158,7 +2158,6 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result let prefixed_user_text = format!("[USER] {user_text}"); test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user_text.to_string(), text_elements: Vec::new(), @@ -2294,7 +2293,6 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re ); test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: user_text.clone(), text_elements: Vec::new(), @@ -3491,7 +3489,6 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 9363bdd3bd..00f5a0c514 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -41,6 +41,7 @@ use core_test_support::responses::sse; 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::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; use core_test_support::wait_for_event; @@ -70,18 +71,21 @@ async fn submit_turn_with_approval_and_environments( prompt: &str, environments: Vec, ) -> Result<()> { + let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new( + test.config.cwd.clone(), + environments, + ); test.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], - environments: Some(environments), final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(turn_environment_selections), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), @@ -284,10 +288,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { let test = unified_exec_test(&server).await?; let local_cwd = TempDir::new()?; fs::write(local_cwd.path().join("marker.txt"), "local-routing")?; - let local_selection = TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }; + let local_selection = local(local_cwd.path().abs()); let remote_cwd = PathBuf::from(format!( "/tmp/codex-remote-routing-{}", SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() @@ -462,10 +463,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result &test, "request permissions, then write in the remote environment", vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), @@ -601,10 +599,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< test.submit_turn_with_environments( "apply patch to remote environment", Some(vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), @@ -682,10 +677,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { .await?; let environments = vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), @@ -882,10 +874,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm test.submit_turn_with_environments( "apply patch through exec command to remote environment", Some(vec![ - TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }, + local(local_cwd.path().abs()), TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 76e7a1c29e..dc8df93c9b 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -37,6 +37,7 @@ use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -158,7 +159,6 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind text: "check context window".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -226,7 +226,6 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re text: "check context window".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -293,7 +292,6 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re text: "check context window".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -374,7 +372,6 @@ async fn remote_models_long_model_slug_is_sent_with_custom_reasoning() -> Result text: "check model slug".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -425,7 +422,6 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() text: "check namespaced model metadata".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -583,12 +579,11 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { text: "run call".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -811,12 +806,11 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { text: "hello remote".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fe9cb1e5f8..e70346a5a3 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -40,7 +40,6 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "compress me".into(), text_elements: Vec::new(), @@ -91,7 +90,6 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "do not compress".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 3beecfc33e..f78ccf1ac7 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -31,6 +31,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -195,12 +196,11 @@ async fn submit_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 9b8761c85c..dc08160d7f 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -28,6 +28,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -147,12 +148,11 @@ async fn submit_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 50cabe416e..902c7a8bb0 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use core_test_support::test_codex::local_selections; use std::collections::HashMap; use codex_features::Feature; @@ -142,12 +143,11 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul text: "please confirm".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -286,12 +286,11 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re text: "please confirm".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -391,12 +390,11 @@ where text: "please confirm".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 8492f08fdd..768bbf11db 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -20,6 +20,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use pretty_assertions::assert_eq; @@ -150,12 +151,11 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 0dc7eaddcb..8f760db417 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -86,7 +86,6 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record some messages".into(), text_elements: text_elements.clone(), @@ -173,7 +172,6 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record reasoning messages".into(), text_elements: Vec::new(), @@ -264,7 +262,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -308,7 +305,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Resume with different model".into(), text_elements: Vec::new(), @@ -327,7 +323,6 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Second turn after resume".into(), text_elements: Vec::new(), @@ -401,7 +396,6 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu .await; codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -440,7 +434,6 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu resumed .codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first turn after override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 5f07d0b39c..3fdd5f83ef 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,6 +23,7 @@ use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -705,7 +706,6 @@ async fn review_history_surfaces_in_parent_session() { let followup = "back to parent".to_string(); codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), @@ -821,7 +821,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(repo_path.to_path_buf().abs()), + environments: Some(local_selections(repo_path.to_path_buf().abs())), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 9745254272..5cb61fa4a1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use anyhow::ensure; +use core_test_support::test_codex::local_selections; use std::collections::HashMap; use std::ffi::OsStr; use std::ffi::OsString; @@ -128,12 +129,11 @@ fn user_turn_with_permission_profile( text: text.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index d21299b1ea..a271702c9c 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -21,6 +21,7 @@ use core_test_support::responses::sse_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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -42,12 +43,11 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { text: text.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index dd8fe60d36..686b9b9e42 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -524,7 +524,6 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let test = builder.build(&server).await?; test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Find the calendar create tool".to_string(), text_elements: Vec::new(), @@ -934,7 +933,6 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Use the automation tool".to_string(), text_elements: Vec::new(), @@ -1245,7 +1243,6 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Find the rmcp echo tool and call it.".to_string(), text_elements: Vec::new(), @@ -1563,7 +1560,6 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch test.codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "Search for the dynamic tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 61ca286005..b6f38b29b1 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -14,6 +14,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -164,12 +165,11 @@ async fn run_snapshot_command_with_options( text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -265,12 +265,11 @@ async fn run_shell_command_snapshot_with_options( text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -346,12 +345,11 @@ async fn run_tool_turn_on_harness( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -590,12 +588,11 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.clone()), + environments: Some(local_selections(cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d60d719cc8..50ea9495ac 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -13,6 +13,7 @@ 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::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; @@ -51,12 +52,11 @@ async fn submit_turn_with_policies( text: prompt.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index c559615d4e..45b52c6ace 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -16,6 +16,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use std::sync::Arc; @@ -85,12 +86,11 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { path: skill_path.clone(), }, ], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index eb9f11a86d..b4809e8a39 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -30,6 +30,7 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -148,7 +149,6 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res started .thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "persist this thread".to_string(), text_elements: Vec::new(), @@ -560,12 +560,11 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< text: "call the rmcp echo tool".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index d82692c26c..1870feab9f 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -94,7 +94,6 @@ async fn continue_after_stream_error() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -117,7 +116,6 @@ async fn continue_after_stream_error() { // error above, this submission would be rejected/queued indefinitely. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 471c60db5a..6e028279b1 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -76,7 +76,6 @@ async fn retries_on_early_close() { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 391b6e5236..4d6cd17bb8 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -30,6 +30,7 @@ use core_test_support::responses::sse_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::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event_match; @@ -768,12 +769,11 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() text: INTERNAL_SUBAGENT_PROMPT.to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index d628c8c49d..7b3f2a6902 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use core_test_support::test_codex::local_selections; use std::fs; use assert_matches::assert_matches; @@ -108,12 +109,11 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res text: "please run the shell command".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -190,12 +190,11 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { text: "please update the plan".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -282,12 +281,11 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { text: "please update the plan".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -384,12 +382,11 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() text: "please apply a patch".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -523,12 +520,11 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { text: "please apply a patch".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd_path), + environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index f7f046fef7..8a9db06803 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used)] +use core_test_support::test_codex::local_selections; use std::fs; use std::time::Duration; use std::time::Instant; @@ -42,12 +43,11 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -369,12 +369,11 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an text: "stream delayed completion".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 7949abfb60..fd19a18c76 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -16,7 +16,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::TurnEnvironmentSelection; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -29,6 +28,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use regex_lite::Regex; use serde_json::Value; @@ -117,10 +117,7 @@ async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<( test.submit_turn_with_environments( "which tools are available?", - Some(vec![TurnEnvironmentSelection { - environment_id: "local".to_string(), - cwd: test.config.cwd.clone(), - }]), + Some(vec![local(test.config.cwd.clone())]), ) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 35a6b0df6d..89ce0013eb 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -22,6 +22,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; @@ -524,12 +525,11 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { text: "call the rmcp image tool".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(fixture.cwd.abs()), + environments: Some(local_selections(fixture.cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 3eb2c6a6d3..caea5ecf55 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,3 +1,4 @@ +use core_test_support::test_codex::local_selections; use std::collections::HashMap; use std::ffi::OsStr; use std::fs; @@ -198,12 +199,11 @@ async fn submit_unified_exec_turn( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -290,12 +290,11 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { text: "apply patch via unified exec".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2146,12 +2145,11 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2250,12 +2248,11 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2723,12 +2720,11 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { text: "summarize large output".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2846,12 +2842,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { text: "read the fixture files".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -2985,12 +2980,11 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { text: "start python under seatbelt".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(turn_cwd), + environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs index 96ad8ee59a..0d913d6541 100644 --- a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs +++ b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs @@ -31,6 +31,7 @@ use core_test_support::responses::sse; 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::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; @@ -406,12 +407,11 @@ async fn submit_turn_with_session_permissions( text: prompt.into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 054d926b2b..328a1c972a 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -57,7 +57,6 @@ mv "${tmp_path}" "${payload_path}""#, // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index fe9715f61e..f7aed6b473 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -21,6 +21,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; @@ -179,12 +180,11 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> text: "run model shell command".to_string(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 5d7263d54b..5820788566 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -42,6 +42,8 @@ use core_test_support::responses::sse; 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::local; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event_with_timeout; @@ -74,12 +76,11 @@ fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); Op::UserInput { items, - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.clone()), + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -456,10 +457,7 @@ async fn view_image_routes_to_selected_local_environment() -> anyhow::Result<()> test.submit_turn_with_environments( "route local view image", - Some(vec![TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: test.config.cwd.clone(), - }]), + Some(vec![local(test.config.cwd.clone())]), ) .await?; @@ -572,10 +570,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let test = builder.build_with_remote_and_local_env(&server).await?; let local_cwd = TempDir::new()?; fs::write(local_cwd.path().join("remote.png"), b"not a remote image")?; - let local_selection = TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: local_cwd.path().abs(), - }; + let local_selection = local(local_cwd.path().abs()); let remote_cwd = PathBuf::from(format!( "/tmp/codex-view-image-routing-{}", SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index b41f8be963..cdf77694c2 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -14,6 +14,7 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use pretty_assertions::assert_eq; @@ -158,12 +159,11 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result text: "hello".into(), text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.abs()), + environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 08d8867f31..7c28eb80a7 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -104,7 +104,6 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { codex .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index fd56805d4e..21793cc696 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -103,7 +103,6 @@ pub async fn run_codex_tool_session( let submission = Submission { id: sub_id.clone(), op: Op::UserInput { - environments: None, items: vec![UserInput::Text { text: initial_prompt.clone(), // MCP tool prompts are plain text with no UI element ranges. @@ -155,7 +154,6 @@ pub async fn run_codex_tool_session_reply( .insert(request_id.clone(), thread_id); if let Err(e) = thread .submit(Op::UserInput { - environments: None, items: vec![UserInput::Text { text: prompt, // MCP tool prompts are plain text with no UI element ranges. diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 58bdc9dfc4..9c15ff04e2 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -259,7 +259,6 @@ impl MemoryStartupContext { .thread .submit(Op::UserInput { items: prompt, - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c9e2e8bb28..642f2e5dee 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -112,6 +112,34 @@ pub struct TurnEnvironmentSelection { pub cwd: AbsolutePathBuf, } +#[derive(Debug, Clone, PartialEq)] +pub struct TurnEnvironmentSelections { + pub legacy_fallback_cwd: AbsolutePathBuf, + pub environments: Vec, +} + +impl TurnEnvironmentSelections { + pub fn new( + legacy_fallback_cwd: AbsolutePathBuf, + environments: Vec, + ) -> Self { + let mut settings = Self { + legacy_fallback_cwd, + environments, + }; + settings.sync_primary_environment_cwd(); + settings + } + + fn sync_primary_environment_cwd(&mut self) { + if let Some(turn_environment) = self.environments.first_mut() + && turn_environment.cwd != self.legacy_fallback_cwd + { + turn_environment.cwd = self.legacy_fallback_cwd.clone(); + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "string")] @@ -370,8 +398,8 @@ pub struct ConversationTextParams { /// on their own. #[derive(Debug, Clone, Default, PartialEq)] pub struct ThreadSettingsOverrides { - /// Updated `cwd` for sandbox/tool calls. - pub cwd: Option, + /// Updated fallback `cwd` and environments supplied together as a complete pair. + pub environments: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. @@ -472,8 +500,6 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, - /// Optional turn-scoped environments. - environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. final_output_json_schema: Option, /// Optional turn-scoped Responses API `client_metadata`. @@ -612,7 +638,6 @@ pub enum ThreadMemoryMode { impl From> for Op { fn from(value: Vec) -> Self { Op::UserInput { - environments: None, items: value, final_output_json_schema: None, responsesapi_client_metadata: None, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 47e0fc3911..91fd7a0b3e 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -296,7 +296,6 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh text: prompt, text_elements: Vec::new(), }], - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), From e0ee491df351ad85d35f879d2e2b8b30c866814f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 8 Jun 2026 13:59:23 -0700 Subject: [PATCH 124/141] cli: add -P sandbox permissions profile alias (#27054) ## Why `codex sandbox --permissions-profile` is useful when running commands under a named permissions profile, but the long option is cumbersome for a debugging-oriented command. `-p` is already used for the config profile selector, so `-P` gives the permissions profile selector a compact, non-conflicting alias. ## What Changed - Added `short = 'P'` to the `permissions_profile` option for the macOS, Linux, and Windows sandbox command structs in [`codex-rs/cli/src/lib.rs`](https://github.com/openai/codex/blob/6d9f9c5cdcaa0a156aa2dabbde259ae5e9e8bc0b/codex-rs/cli/src/lib.rs#L29-L112). - Added parser coverage for `codex sandbox -P :workspace -- echo` in [`codex-rs/cli/src/main.rs`](https://github.com/openai/codex/blob/6d9f9c5cdcaa0a156aa2dabbde259ae5e9e8bc0b/codex-rs/cli/src/main.rs#L2883-L2896). ## Verification - `just test -p codex-cli` passed, including the new `sandbox_parses_permissions_profile_short_alias` parser test. --- codex-rs/cli/src/lib.rs | 6 +++--- codex-rs/cli/src/main.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 5e2ba0caa5..4e63df187e 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -26,7 +26,7 @@ pub use login::run_logout; #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Named permissions profile to apply from the active configuration stack. - #[arg(long = "permissions-profile", value_name = "NAME")] + #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, /// Layer $CODEX_HOME/.config.toml on top of the base user config. @@ -74,7 +74,7 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result { #[derive(Debug, Parser)] pub struct LandlockCommand { /// Named permissions profile to apply from the active configuration stack. - #[arg(long = "permissions-profile", value_name = "NAME")] + #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, /// Layer $CODEX_HOME/.config.toml on top of the base user config. @@ -109,7 +109,7 @@ pub struct LandlockCommand { #[derive(Debug, Parser)] pub struct WindowsCommand { /// Named permissions profile to apply from the active configuration stack. - #[arg(long = "permissions-profile", value_name = "NAME")] + #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, /// Layer $CODEX_HOME/.config.toml on top of the base user config. diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index bb0daf3890..b194a5ab1b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2880,6 +2880,21 @@ mod tests { assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_parses_permissions_profile_short_alias() { + let cli = + MultitoolCli::try_parse_from(["codex", "sandbox", "-P", ":workspace", "--", "echo"]) + .expect("parse"); + + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + + assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); + assert_eq!(command.command, vec!["echo"]); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn sandbox_parses_config_profile() { From 85fd52f7e4aebfc39229b50e6f0c092dd00ca343 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 8 Jun 2026 14:03:37 -0700 Subject: [PATCH 125/141] Enforce configured network proxy in codex sandbox (#27035) ## Why `codex sandbox` can start a network proxy from a configured permission profile. Previously, sandbox-level containment was tied to managed network requirements rather than whether a proxy was actually active. This meant config-driven proxy policies were not consistently enforced as the sandbox's only network path. ## What changed - Enable proxy-only network containment whenever `codex sandbox` starts a network proxy. - Apply the same active-proxy check to the macOS and Linux sandbox paths. - Add a Linux regression test that verifies a sandboxed command cannot establish a direct connection while the configured proxy is active. ## Test plan - `just test -p codex-cli debug_sandbox::tests` - `sandbox_with_network_proxy_blocks_direct_loopback_access` runs on Linux to cover the config-driven proxy path end to end. --- codex-rs/cli/BUILD.bazel | 3 + codex-rs/cli/src/debug_sandbox.rs | 7 ++- codex-rs/cli/tests/sandbox_network_proxy.rs | 70 +++++++++++++++++++++ codex-rs/sandboxing/src/landlock.rs | 6 +- codex-rs/sandboxing/src/landlock_tests.rs | 7 ++- codex-rs/sandboxing/src/manager.rs | 4 +- codex-rs/sandboxing/src/manager_tests.rs | 9 +++ 7 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 codex-rs/cli/tests/sandbox_network_proxy.rs diff --git a/codex-rs/cli/BUILD.bazel b/codex-rs/cli/BUILD.bazel index a8a97cef00..faa8627477 100644 --- a/codex-rs/cli/BUILD.bazel +++ b/codex-rs/cli/BUILD.bazel @@ -3,6 +3,9 @@ load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate", "multip codex_rust_crate( name = "cli", crate_name = "codex_cli", + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], rustc_flags_extra = MACOS_WEBRTC_RUSTC_LINK_FLAGS, ) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6d59bf7d23..a1931aced0 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -258,6 +258,9 @@ async fn run_command_under_sandbox( let network = network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy); + // Proxy containment depends on whether a proxy is active, not whether its + // policy came from managed requirements. + let enforce_managed_network = network.is_some(); let managed_mitm_ca_trust_bundle_path = match network.as_ref() { Some(network) => network.managed_mitm_ca_trust_bundle_path(), None => None, @@ -278,7 +281,7 @@ async fn run_command_under_sandbox( file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), - enforce_managed_network: false, + enforce_managed_network, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, }); @@ -311,7 +314,7 @@ async fn run_command_under_sandbox( &runtime_permission_profile, sandbox_policy_cwd.as_path(), use_legacy_landlock, - allow_network_for_proxy(managed_network_requirements_enabled), + allow_network_for_proxy(enforce_managed_network), ); spawn_debug_sandbox_child( codex_linux_sandbox_exe, diff --git a/codex-rs/cli/tests/sandbox_network_proxy.rs b/codex-rs/cli/tests/sandbox_network_proxy.rs new file mode 100644 index 0000000000..22bdcce49d --- /dev/null +++ b/codex-rs/cli/tests/sandbox_network_proxy.rs @@ -0,0 +1,70 @@ +#![cfg(target_os = "linux")] + +use std::net::TcpListener; + +use anyhow::Result; +use tempfile::TempDir; + +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable"; + +#[test] +fn sandbox_with_network_proxy_blocks_direct_loopback_access() -> Result<()> { + let codex_home = TempDir::new()?; + let listener = TcpListener::bind("127.0.0.2:0")?; + let port = listener.local_addr()?.port(); + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = "network-test" + +[features] +network_proxy = true +use_legacy_landlock = true + +[permissions.network-test] +extends = ":workspace" + +[permissions.network-test.network] +enabled = true +mode = "full" +"#, + )?; + + let url = format!("http://127.0.0.2:{port}/"); + let output = std::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) + .env("CODEX_HOME", codex_home.path()) + .args([ + "sandbox", + "--permissions-profile", + "network-test", + "--", + "curl", + "--noproxy", + "*", + "--silent", + "--show-error", + "--connect-timeout", + "1", + "--max-time", + "2", + url.as_str(), + ]) + .output()?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains(BWRAP_UNAVAILABLE_ERR) { + eprintln!("skipping network proxy sandbox test: bubblewrap is unavailable"); + return Ok(()); + } + + assert_eq!( + output.status.code(), + Some(7), + "expected direct loopback access to be blocked; status={:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + stderr, + ); + + Ok(()) +} diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs index 0ff3f6977f..819fc9365e 100644 --- a/codex-rs/sandboxing/src/landlock.rs +++ b/codex-rs/sandboxing/src/landlock.rs @@ -47,7 +47,8 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( "--permission-profile".to_string(), permission_profile_json, ]; - if use_legacy_landlock { + // Proxy-only networking requires bubblewrap's isolated network namespace. + if use_legacy_landlock && !allow_network_for_proxy { linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { @@ -83,7 +84,8 @@ fn create_linux_sandbox_command_args( "--command-cwd".to_string(), command_cwd, ]; - if use_legacy_landlock { + // Proxy-only networking requires bubblewrap's isolated network namespace. + if use_legacy_landlock && !allow_network_for_proxy { linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { diff --git a/codex-rs/sandboxing/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs index 14b1c047eb..d0bbab8400 100644 --- a/codex-rs/sandboxing/src/landlock_tests.rs +++ b/codex-rs/sandboxing/src/landlock_tests.rs @@ -33,14 +33,16 @@ fn legacy_landlock_flag_is_included_when_requested() { } #[test] -fn proxy_flag_is_included_when_requested() { +fn proxy_flag_takes_precedence_over_legacy_landlock() { let command = vec!["/bin/true".to_string()]; let command_cwd = Path::new("/tmp/link"); let cwd = Path::new("/tmp"); + let permission_profile = PermissionProfile::read_only(); - let args = create_linux_sandbox_command_args( + let args = create_linux_sandbox_command_args_for_permission_profile( command, command_cwd, + &permission_profile, cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ true, @@ -49,6 +51,7 @@ fn proxy_flag_is_included_when_requested() { args.contains(&"--allow-network-for-proxy".to_string()), true ); + assert_eq!(args.contains(&"--use-legacy-landlock".to_string()), false); } #[test] diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 39ef358f8c..682e8e3dcd 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -338,8 +338,8 @@ fn ensure_linux_bubblewrap_is_supported( allow_network_for_proxy: bool, is_wsl1: bool, ) -> Result<(), SandboxTransformError> { - let requires_bubblewrap = !use_legacy_landlock - && (!file_system_sandbox_policy.has_full_disk_write_access() || allow_network_for_proxy); + let requires_bubblewrap = allow_network_for_proxy + || (!use_legacy_landlock && !file_system_sandbox_policy.has_full_disk_write_access()); if is_wsl1 && requires_bubblewrap { return Err(SandboxTransformError::Wsl1UnsupportedForBubblewrap); } diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index fede00fe1e..58138ad775 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -342,6 +342,15 @@ fn wsl1_rejects_linux_bubblewrap_path() { ), Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) )); + assert!(matches!( + super::ensure_linux_bubblewrap_is_supported( + &FileSystemSandboxPolicy::unrestricted(), + /*use_legacy_landlock*/ true, + /*allow_network_for_proxy*/ true, + /*is_wsl1*/ true, + ), + Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) + )); } #[cfg(target_os = "linux")] From 123cf62a485de23a8960260f5bb00b86af6778c5 Mon Sep 17 00:00:00 2001 From: Won Park Date: Mon, 8 Jun 2026 14:23:55 -0700 Subject: [PATCH 126/141] Route image edits through referenced file paths (#26486) ## Why Image edits should use the exact images selected by the model instead of inferring edit inputs from conversation history. ## What changed - Replaced the image tool's `action` argument with optional `referenced_image_paths`. - Treats omitted or empty references as generation and populated references as editing. - Reads referenced absolute image paths and packages them as image data URLs for the edit request. - Removed the previous history-selection and image-count heuristics. - Updated direct and code-mode tool instructions and calls. - Added an app-server integration test covering an attached image routed to the image edit endpoint. ## Validation - Tested end-to-end on local `just codex` with copy pasted image, attached image, etc. - `just test -p codex-image-generation-extension` - `just test -p codex-app-server standalone_image_edit_uses_attached_model_visible_image` - `just fix -p codex-image-generation-extension` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + .../tests/suite/v2/imagegen_extension.rs | 160 +++++++- codex-rs/ext/image-generation/Cargo.toml | 1 + .../image-generation/imagegen_description.md | 11 +- codex-rs/ext/image-generation/src/tests.rs | 372 ++++++++---------- codex-rs/ext/image-generation/src/tool.rs | 252 +++++++----- 6 files changed, 471 insertions(+), 326 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ec5bfebf7b..8b62cb5210 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3106,6 +3106,7 @@ dependencies = [ "codex-protocol", "codex-tools", "codex-utils-absolute-path", + "codex-utils-image", "http 1.4.0", "pretty_assertions", "schemars 0.8.22", diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index b6593bc820..c73b5593c6 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -29,6 +29,11 @@ use wiremock::matchers::method; use wiremock::matchers::path; const RESULT: &str = "cG5n"; +const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, + 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, +]; #[derive(Clone, Copy)] enum ImagegenTestMode { @@ -59,7 +64,6 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul "image_gen", "imagegen", &json!({ - "action": "generate", "prompt": "paint a blue whale", }) .to_string(), @@ -142,6 +146,67 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul Ok(()) } +#[tokio::test] +async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()> { + let edit_request = run_image_edit_test(|codex_home| { + let image_path = codex_home.join("attached.png"); + std::fs::write(&image_path, TINY_PNG_BYTES)?; + Ok(( + json!({ + "prompt": "add a red hat", + "referenced_image_paths": [image_path.display().to_string()], + }), + vec![ + V2UserInput::Text { + text: "Edit the attached image".to_string(), + text_elements: Vec::new(), + }, + V2UserInput::LocalImage { + path: image_path, + detail: None, + }, + ], + )) + }) + .await?; + assert_eq!(edit_request["prompt"], "add a red hat"); + assert!( + edit_request["images"][0]["image_url"] + .as_str() + .is_some_and(|image_url| image_url.starts_with("data:image/png;base64,")) + ); + + Ok(()) +} + +#[tokio::test] +async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> { + let image_url = "https://example.com/reference.png"; + let edit_request = run_image_edit_test(|_| { + Ok(( + json!({ + "prompt": "add a red hat", + "num_last_images_to_include": 1, + }), + vec![ + V2UserInput::Text { + text: "Edit the attached image".to_string(), + text_elements: Vec::new(), + }, + V2UserInput::Image { + url: image_url.to_string(), + detail: None, + }, + ], + )) + }) + .await?; + assert_eq!(edit_request["prompt"], "add a red hat"); + assert_eq!(edit_request["images"][0]["image_url"], image_url); + + Ok(()) +} + #[tokio::test] async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<()> { let server = responses::start_mock_server().await; @@ -202,7 +267,6 @@ async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result "exec", r#" const result = await tools.image_gen__imagegen({ - action: "generate", prompt: "paint a blue whale", }); generatedImage(result); @@ -263,6 +327,81 @@ generatedImage(result); } async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> { + start_turn( + mcp, + vec![V2UserInput::Text { + text: "Generate an image".to_string(), + text_elements: Vec::new(), + }], + ) + .await +} + +async fn run_image_edit_test( + input: impl FnOnce(&Path) -> Result<(serde_json::Value, Vec)>, +) -> Result { + let call_id = "image-edit-1"; + let server = responses::start_mock_server().await; + mount_image_edit_response(&server).await; + + let codex_home = TempDir::new()?; + let (arguments, input) = input(codex_home.path())?; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "image_gen", + "imagegen", + &arguments.to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + start_turn(&mut mcp, input).await?; + timeout( + DEFAULT_READ_TIMEOUT, + wait_for_image_generation_completed(&mut mcp), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!(response_mock.requests().len(), 2); + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + Ok(requests + .iter() + .find(|request| request.url.path() == "/api/codex/images/edits") + .context("image edit request should be sent")? + .body_json::()?) +} + +async fn start_turn(mcp: &mut TestAppServer, input: Vec) -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams::default()) .await?; @@ -277,10 +416,7 @@ async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> { .send_turn_start_request(TurnStartParams { thread_id: thread.id, client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Generate an image".to_string(), - text_elements: Vec::new(), - }], + input, ..Default::default() }) .await?; @@ -324,6 +460,18 @@ async fn mount_image_response(server: &MockServer) { .await; } +async fn mount_image_edit_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/images/edits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "created": 1, + "data": [{"b64_json": RESULT}], + }))) + .expect(1) + .mount(server) + .await; +} + fn create_config_toml( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index c6b87e1d4f..cb5ddda0a8 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -23,6 +23,7 @@ codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-image = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md index 1e5a46390f..53e6620135 100644 --- a/codex-rs/ext/image-generation/imagegen_description.md +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -5,8 +5,13 @@ The `image_gen.imagegen` tool enables image generation from descriptions and edi Guidelines: - In code mode, pass the result to `generatedImage(result)`. -- Set `action` to `generate` when the user asks for a brand new image. -- Set `action` to `edit` when the user asks to modify an existing image from the conversation history. -- Directly generate the image without reconfirmation or clarification. +- Omit both `referenced_image_paths` and `num_last_images_to_include` when generating a brand new image. +- For edits, use `referenced_image_paths` when every target image has a local file path. +- If you have not seen a local image yet, use `view_image` to inspect it before editing. +- Use `num_last_images_to_include` only when at least one target image has no local file path. +- Set `num_last_images_to_include` to the smallest number of recent conversation images that includes every target image, up to 5. +- Never provide both `referenced_image_paths` and `num_last_images_to_include`. +- If neither mechanism can include every target image, ask the user to attach the missing images again. +- Directly generate the image without reconfirmation or clarification unless required images must be attached again. - After each image generation, do not mention anything related to download. Do not summarize the image. Do not ask followup question. Do not say ANYTHING after you generate an image. - Always use this tool for image editing unless the user explicitly requests otherwise. Do not use the `python` tool for image editing unless specifically instructed. diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 6a974e2d51..3f72972656 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -19,10 +19,9 @@ use pretty_assertions::assert_eq; use super::GeneratedImageOutput; use super::ImageRequest; -use super::ImagegenAction; use super::ImagegenArgs; use super::imagegen_tool_spec; -use super::request_for_action; +use super::request_for_args; use crate::IMAGE_GEN_NAMESPACE; use crate::IMAGEGEN_TOOL_NAME; @@ -39,10 +38,17 @@ fn uses_reserved_image_gen_namespace() { } #[test] -fn generate_uses_fixed_request_defaults() { +fn omitted_references_generate_with_fixed_defaults() { assert_eq!( - request_for_action(&args(ImagegenAction::Generate, "paint a moonlit lake"), &[]) - .expect("generation request should build"), + request_for_args( + &ImagegenArgs { + prompt: "paint a moonlit lake".to_string(), + referenced_image_paths: None, + num_last_images_to_include: None, + }, + &[] + ) + .expect("generation request should build"), ImageRequest::Generate(ImageGenerationRequest { prompt: "paint a moonlit lake".to_string(), background: Some(ImageBackground::Auto), @@ -54,6 +60,144 @@ fn generate_uses_fixed_request_defaults() { ); } +#[test] +fn recent_image_fallback_selects_newest_images_in_chronological_order() { + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + input_image("user-1"), + input_image("user-2"), + ContentItem::InputText { + text: "edit these".to_string(), + }, + ], + phase: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "mcp_image".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "mcp-call".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "mcp-call".to_string(), + output: image_output("mcp"), + }, + ResponseItem::CustomToolCall { + id: None, + status: Some("completed".to_string()), + call_id: "code-mode-call".to_string(), + name: "exec".to_string(), + input: String::new(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "code-mode-call".to_string(), + name: Some("exec".to_string()), + output: image_output("code-mode"), + }, + ResponseItem::ImageGenerationCall { + id: "generated-call".to_string(), + status: "completed".to_string(), + revised_prompt: None, + result: "generated".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "orphan-call".to_string(), + output: image_output("orphan"), + }, + ]; + + assert_eq!( + request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: None, + num_last_images_to_include: Some(4), + }, + &history, + ) + .expect("history-backed edit request should build"), + ImageRequest::Edit(expected_edit_request( + "change the lighting", + &["user-2", "mcp", "code-mode", "generated"], + )) + ); +} + +#[test] +fn conflicting_image_selectors_return_tool_error() { + let error = request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: Some(vec![ + "/tmp/image.png" + .try_into() + .expect("test path should be absolute"), + ]), + num_last_images_to_include: Some(1), + }, + &[], + ) + .expect_err("conflicting selectors should fail"); + + assert_eq!( + error.to_string(), + "provide only one of `referenced_image_paths` or `num_last_images_to_include`" + ); +} + +#[test] +fn too_many_referenced_image_paths_return_tool_error() { + let error = request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: Some( + (0..6) + .map(|index| { + format!("/tmp/image-{index}.png") + .try_into() + .expect("test path should be absolute") + }) + .collect(), + ), + num_last_images_to_include: None, + }, + &[], + ) + .expect_err("too many paths should fail before reading files"); + + assert_eq!( + error.to_string(), + "`referenced_image_paths` must contain at most 5 paths" + ); +} + +#[test] +fn recent_image_fallback_requires_requested_count() { + let error = request_for_args( + &ImagegenArgs { + prompt: "change the lighting".to_string(), + referenced_image_paths: None, + num_last_images_to_include: Some(2), + }, + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![input_image("only-image")], + phase: None, + }], + ) + .expect_err("history-backed edit should require the requested image count"); + + assert_eq!( + error.to_string(), + "requested the last 2 conversation images, but only 1 were available" + ); +} + #[test] fn generated_output_returns_image_input_and_output_hint() { let output_hint = @@ -128,195 +272,26 @@ fn generated_output_omits_oversized_output_hint() { ); } -#[test] -fn edit_matches_context_selector_for_generated_images_after_latest_user_anchor() { - let history = vec![ - generated_item("g1"), - generated_item("g2"), - generated_item("g3"), - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputImage { - image_url: "data:image/png;base64,u1".to_string(), - detail: None, - }, - ContentItem::InputImage { - image_url: "data:image/png;base64,u2".to_string(), - detail: None, - }, - ], - phase: None, - }, - generated_item("g4"), - generated_item("g5"), - generated_item("g6"), - generated_item("g7"), - ]; - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request( - "change the lighting", - &[ - "data:image/png;base64,u1", - "data:image/png;base64,u2", - "data:image/png;base64,g5", - "data:image/png;base64,g6", - "data:image/png;base64,g7", - ] - ) - ); -} - -#[test] -fn edit_preserves_a_generated_image_when_user_anchor_fills_the_limit() { - let history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: ["a", "b", "c", "d", "e"] - .into_iter() - .map(|image| ContentItem::InputImage { - image_url: format!("data:image/png;base64,{image}"), - detail: None, - }) - .collect(), - phase: None, - }, - generated_item("generated"), - ]; - - assert_eq!( - edit_request("edit the last generated image", &history), - expected_edit_request( - "edit the last generated image", - &[ - "data:image/png;base64,b", - "data:image/png;base64,c", - "data:image/png;base64,d", - "data:image/png;base64,e", - "data:image/png;base64,generated", - ] - ) - ); -} - -#[test] -fn edit_uses_latest_user_upload_before_a_text_only_follow_up() { - let history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputImage { - image_url: "data:image/png;base64,user".to_string(), - detail: None, - }], - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "edit this image".to_string(), - }], - phase: None, - }, - ]; - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request("change the lighting", &["data:image/png;base64,user"]) - ); -} - -#[test] -fn edit_reuses_images_from_prior_standalone_imagegen_calls() { - let history = vec![ - ResponseItem::FunctionCall { - id: None, - name: IMAGEGEN_TOOL_NAME.to_string(), - namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), - arguments: "{}".to_string(), - call_id: "imagegen-1".to_string(), - }, - generated_function_output("imagegen-1", "standalone"), - ]; - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request("change the lighting", &["data:image/png;base64,standalone"]) - ); -} - -#[test] -fn edit_keeps_newest_standalone_generated_images_when_over_limit() { - let history = (1..=6) - .flat_map(|index| { - let call_id = format!("imagegen-{index}"); - vec![ - ResponseItem::FunctionCall { - id: None, - name: IMAGEGEN_TOOL_NAME.to_string(), - namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), - arguments: "{}".to_string(), - call_id: call_id.clone(), - }, - generated_function_output(&call_id, &index.to_string()), - ] - }) - .collect::>(); - - assert_eq!( - edit_request("change the lighting", &history), - expected_edit_request( - "change the lighting", - &[ - "data:image/png;base64,2", - "data:image/png;base64,3", - "data:image/png;base64,4", - "data:image/png;base64,5", - "data:image/png;base64,6", - ] - ) - ); -} - -#[test] -fn edit_without_image_history_returns_tool_error() { - let error = request_for_action(&args(ImagegenAction::Edit, "change the lighting"), &[]) - .expect_err("edit should require image context"); - - assert_eq!( - error.to_string(), - "image edit requested without any usable image in conversation history" - ); -} - -fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs { - ImagegenArgs { - prompt: prompt.to_string(), - action, +fn input_image(image: &str) -> ContentItem { + ContentItem::InputImage { + image_url: format!("data:image/png;base64,{image}"), + detail: None, } } -fn edit_request(prompt: &str, history: &[ResponseItem]) -> ImageEditRequest { - let ImageRequest::Edit(request) = - request_for_action(&args(ImagegenAction::Edit, prompt), history) - .expect("edit request should build") - else { - panic!("expected edit request"); - }; - request +fn image_output(image: &str) -> FunctionCallOutputPayload { + FunctionCallOutputPayload::from_content_items(vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{image}"), + detail: None, + }]) } fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest { ImageEditRequest { images: images .iter() - .map(|image_url| ImageUrl { - image_url: (*image_url).to_string(), + .map(|image| ImageUrl { + image_url: format!("data:image/png;base64,{image}"), }) .collect(), prompt: prompt.to_string(), @@ -328,33 +303,6 @@ fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest { } } -fn generated_item(result: &str) -> ResponseItem { - ResponseItem::ImageGenerationCall { - id: format!("id-{result}"), - status: "completed".to_string(), - revised_prompt: None, - result: result.to_string(), - } -} - -fn generated_function_output(call_id: &str, result: &str) -> ResponseItem { - ResponseItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::ContentItems(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: format!("data:image/png;base64,{result}"), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: "generated image save hint".to_string(), - }, - ]), - success: Some(true), - }, - } -} - fn function_payload() -> ToolPayload { ToolPayload::Function { arguments: "{}".to_string(), diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 41c06c8007..69b99bdbc4 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use codex_api::ImageBackground; use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; @@ -28,6 +30,8 @@ use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_image::PromptImageMode; +use codex_utils_image::load_for_prompt_bytes; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; @@ -68,14 +72,10 @@ impl ImageGenerationTool { #[serde(deny_unknown_fields)] struct ImagegenArgs { prompt: String, - action: ImagegenAction, -} - -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -enum ImagegenAction { - Generate, - Edit, + #[schemars(length(max = 5))] + referenced_image_paths: Option>, + #[schemars(range(min = 1, max = 5))] + num_last_images_to_include: Option, } #[async_trait::async_trait] @@ -85,7 +85,7 @@ impl ToolExecutor for ImageGenerationTool { ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) } - /// Advertises the model contract: a rewritten prompt and semantic action. + /// Advertises the model contract: a rewritten prompt and optional edit references. fn spec(&self) -> ToolSpec { imagegen_tool_spec() } @@ -98,7 +98,7 @@ impl ToolExecutor for ImageGenerationTool { /// Executes the selected image operation and returns the completed image result. async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; - let request = request_for_action(&args, call.conversation_history.items())?; + let request = request_for_args(&args, call.conversation_history.items())?; call.turn_item_emitter .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { id: call.call_id.clone(), @@ -149,106 +149,84 @@ enum ImageRequest { Edit(ImageEditRequest), } -/// Maps the model-selected action to the fixed image API request parameters. -fn request_for_action( +/// Builds a generation or edit request from the mutually exclusive image selectors. +fn request_for_args( args: &ImagegenArgs, history: &[ResponseItem], ) -> Result { - match args.action { - ImagegenAction::Generate => Ok(ImageRequest::Generate(ImageGenerationRequest { - prompt: args.prompt.clone(), - background: Some(ImageBackground::Auto), - model: IMAGE_MODEL.to_string(), - n: None, - quality: Some(ImageQuality::Auto), - size: Some("auto".to_string()), - })), - ImagegenAction::Edit => { - let images = edit_images(history); - if images.is_empty() { - return Err(FunctionCallError::RespondToModel( - "image edit requested without any usable image in conversation history" - .to_string(), - )); - } - Ok(ImageRequest::Edit(ImageEditRequest { - images, + let paths = args.referenced_image_paths.as_deref().unwrap_or_default(); + if paths.len() > MAX_EDIT_IMAGES { + return Err(FunctionCallError::RespondToModel(format!( + "`referenced_image_paths` must contain at most {MAX_EDIT_IMAGES} paths" + ))); + } + let images = match (paths.is_empty(), args.num_last_images_to_include) { + (true, None) => { + return Ok(ImageRequest::Generate(ImageGenerationRequest { prompt: args.prompt.clone(), background: Some(ImageBackground::Auto), model: IMAGE_MODEL.to_string(), n: None, quality: Some(ImageQuality::Auto), size: Some("auto".to_string()), - })) + })); } - } + (false, None) => paths.iter().map(image_url).collect::, _>>()?, + (true, Some(count)) => { + if !(1..=MAX_EDIT_IMAGES).contains(&count) { + return Err(FunctionCallError::RespondToModel(format!( + "`num_last_images_to_include` must be between 1 and {MAX_EDIT_IMAGES}" + ))); + } + // Pathless images have no stable reference, so this bounded window may include newer + // unrelated images. This remains best-effort until the harness provides stable refs. + let images = recent_images(history, count); + if images.len() != count { + return Err(FunctionCallError::RespondToModel(format!( + "requested the last {count} conversation images, but only {} were available", + images.len() + ))); + } + images + } + (false, Some(_)) => { + return Err(FunctionCallError::RespondToModel( + "provide only one of `referenced_image_paths` or \ + `num_last_images_to_include`" + .to_string(), + )); + } + }; + + Ok(ImageRequest::Edit(ImageEditRequest { + images, + prompt: args.prompt.clone(), + background: Some(ImageBackground::Auto), + model: IMAGE_MODEL.to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + })) } -/// Selects edit context using the hosted imagegen anchor and truncation behavior. -fn edit_images(history: &[ResponseItem]) -> Vec { - let latest_uploaded_images = history.iter().enumerate().rev().find_map(|(index, item)| { - let ResponseItem::Message { role, content, .. } = item else { - return None; - }; - if role != "user" { - return None; - } - let images = content - .iter() - .filter_map(|item| match item { - ContentItem::InputImage { image_url, .. } => Some(ImageUrl { - image_url: image_url.clone(), - }), - ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None, - }) - .collect::>(); - (!images.is_empty()).then_some((index, images)) - }); - let (user_images, follow_up_start) = latest_uploaded_images - .map_or_else(|| (Vec::new(), 0), |(index, images)| (images, index + 1)); - let mut generated_images = Vec::new(); - for item in &history[follow_up_start..] { +/// Selects the newest requested images while preserving their conversation order. +fn recent_images(history: &[ResponseItem], count: usize) -> Vec { + let mut function_call_ids = HashSet::new(); + let mut custom_tool_call_ids = HashSet::new(); + for item in history { match item { - ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { - generated_images.push(ImageUrl { - image_url: format!("data:image/png;base64,{result}"), - }); + ResponseItem::FunctionCall { call_id, .. } => { + function_call_ids.insert(call_id.as_str()); } - ResponseItem::FunctionCallOutput { call_id, output } - if history.iter().any(|item| { - matches!( - item, - ResponseItem::FunctionCall { - name, - namespace: Some(namespace), - call_id: function_call_id, - .. - } if function_call_id == call_id - && name == IMAGEGEN_TOOL_NAME - && namespace == IMAGE_GEN_NAMESPACE - ) - }) => - { - generated_images.extend(output.content_items().into_iter().flatten().filter_map( - |item| match item { - FunctionCallOutputContentItem::InputImage { image_url, .. } => { - Some(ImageUrl { - image_url: image_url.clone(), - }) - } - FunctionCallOutputContentItem::InputText { .. } - | FunctionCallOutputContentItem::EncryptedContent { .. } => None, - }, - )); + ResponseItem::CustomToolCall { call_id, .. } => { + custom_tool_call_ids.insert(call_id.as_str()); } ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } @@ -259,25 +237,89 @@ fn edit_images(history: &[ResponseItem]) -> Vec { | ResponseItem::Other => {} } } - truncate_images(user_images, generated_images) + + let mut images = Vec::with_capacity(count); + 'history: for item in history.iter().rev() { + let mut image_urls = Vec::new(); + match item { + ResponseItem::Message { content, .. } => { + image_urls.extend(content.iter().rev().filter_map(|item| match item { + ContentItem::InputImage { image_url, .. } => Some(image_url.clone()), + ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None, + })); + } + ResponseItem::FunctionCallOutput { call_id, output } + if function_call_ids.contains(call_id.as_str()) => + { + image_urls.extend(output_image_urls(output)); + } + ResponseItem::CustomToolCallOutput { + call_id, output, .. + } if custom_tool_call_ids.contains(call_id.as_str()) => { + image_urls.extend(output_image_urls(output)); + } + ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { + image_urls.push(format!("data:image/png;base64,{result}")); + } + ResponseItem::Reasoning { .. } + | ResponseItem::AgentMessage { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => {} + } + for image_url in image_urls { + images.push(ImageUrl { image_url }); + if images.len() == count { + break 'history; + } + } + } + images.reverse(); + images } -/// Truncates edit inputs while preserving the newest generated image when possible. -fn truncate_images( - mut user_images: Vec, - mut generated_images: Vec, -) -> Vec { - let mut excess = (user_images.len() + generated_images.len()).saturating_sub(MAX_EDIT_IMAGES); - let drop_generated = excess.min(generated_images.len().saturating_sub(1)); - generated_images.drain(..drop_generated); - excess -= drop_generated; - let drop_user = excess.min(user_images.len()); - user_images.drain(..drop_user); - excess -= drop_user; - generated_images.drain(..excess); +/// Extracts image URLs from a tool output in newest-first order. +fn output_image_urls(output: &FunctionCallOutputPayload) -> impl Iterator + '_ { + output + .content_items() + .into_iter() + .flatten() + .rev() + .filter_map(|item| match item { + FunctionCallOutputContentItem::InputImage { image_url, .. } => Some(image_url.clone()), + FunctionCallOutputContentItem::InputText { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, + }) +} - user_images.extend(generated_images); - user_images +fn image_url(path: &AbsolutePathBuf) -> Result { + let bytes = std::fs::read(path).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "unable to read referenced image at `{}`: {error}", + path.display() + )) + })?; + let image = load_for_prompt_bytes(path.as_path(), bytes, PromptImageMode::Original).map_err( + |error| { + FunctionCallError::RespondToModel(format!( + "unable to process referenced image at `{}`: {error}", + path.display() + )) + }, + )?; + Ok(ImageUrl { + image_url: image.into_data_url(), + }) } /// Parses the strict model-facing arguments for an image-generation call. From ee6c91d5cfd0e63239c75b41f4a2dc14130d5688 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 8 Jun 2026 14:29:06 -0700 Subject: [PATCH 127/141] [codex-analytics] stop sending codex error subreason (#27060) ## Summary - stop emitting `codex_error_subreason` on `codex_turn_event` - remove the transient analytics fact plumbing that copied `CodexErr::InvalidRequest(String)` into the event - update analytics serialization coverage accordingly ## Why `codex_error_subreason` is a free-form copy of `InvalidRequest(String)`, including raw provider 400 bodies in some paths. That makes it unsafe as an analytics field because it can carry user-derived or sensitive text. ## Validation - `just fmt` - `just test -p codex-analytics` --- .../analytics/src/analytics_client_tests.rs | 6 ----- codex-rs/analytics/src/events.rs | 1 - codex-rs/analytics/src/facts.rs | 24 ------------------- codex-rs/analytics/src/reducer.rs | 1 - 4 files changed, 32 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a3b6dfa949..e94d69ed5b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3318,7 +3318,6 @@ fn turn_event_serializes_expected_shape() { status: Some(TurnStatus::Completed), turn_error: None, codex_error_kind: None, - codex_error_subreason: None, codex_error_http_status_code: None, steer_count: Some(0), total_tool_call_count: None, @@ -3391,7 +3390,6 @@ fn turn_event_serializes_expected_shape() { "status": "completed", "turn_error": null, "codex_error_kind": null, - "codex_error_subreason": null, "codex_error_http_status_code": null, "steer_count": 0, "total_tool_call_count": null, @@ -4098,10 +4096,6 @@ async fn turn_lifecycle_emits_failed_turn_event() { payload["event_params"]["codex_error_kind"], json!("invalid_request") ); - assert_eq!( - payload["event_params"]["codex_error_subreason"], - json!("unknown turn environment id `env-2`") - ); assert_eq!( payload["event_params"]["codex_error_http_status_code"], json!(null) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 019d59776a..0b6c9a0122 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -803,7 +803,6 @@ pub(crate) struct CodexTurnEventParams { pub(crate) status: Option, pub(crate) turn_error: Option, pub(crate) codex_error_kind: Option, - pub(crate) codex_error_subreason: Option, pub(crate) codex_error_http_status_code: Option, pub(crate) steer_count: Option, pub(crate) total_tool_call_count: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index c9d245ce90..a035bc98b9 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -30,9 +30,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; use std::path::PathBuf; -const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512; -const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "..."; - #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AcceptedLineFingerprint { pub path_hash: String, @@ -182,7 +179,6 @@ pub(crate) enum CodexErrKind { #[derive(Clone)] pub(crate) struct TurnCodexError { pub(crate) kind: CodexErrKind, - pub(crate) subreason: Option, pub(crate) http_status_code: Option, } @@ -190,26 +186,6 @@ impl TurnCodexError { fn from_codex_err(error: &CodexErr) -> Self { Self { kind: error.into(), - subreason: match error { - CodexErr::InvalidRequest(message) => { - // InvalidRequest can contain raw provider response bodies, so bound the - // analytics copy without changing the source CodexErr. - let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES { - message.clone() - } else { - let truncated_len = message.floor_char_boundary( - INVALID_REQUEST_SUBREASON_MAX_BYTES - .saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()), - ); - format!( - "{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}", - &message[..truncated_len] - ) - }; - Some(subreason) - } - _ => None, - }, http_status_code: error.http_status_code_value(), } } diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 66caaafe01..e159c0f2a1 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2470,7 +2470,6 @@ fn codex_turn_event_params( status: completed.status, turn_error: completed.turn_error, codex_error_kind: codex_error.map(|error| error.kind), - codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()), codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code), steer_count: Some(turn_state.steer_count), total_tool_call_count: Some(turn_state.tool_counts.total), From 56554904babcaacf4444a2cc90716880837dff7c Mon Sep 17 00:00:00 2001 From: fchen-oai Date: Mon, 8 Jun 2026 14:33:57 -0700 Subject: [PATCH 128/141] [codex] Require complete main-agent skill reads (#27044) ## Summary - require the main agent to read selected `SKILL.md` files completely, continuing truncated or paginated reads through EOF - require the main agent to personally read task-required instruction references instead of delegating their interpretation - clarify that progressive disclosure selects relevant files without permitting partial reads - preserve subagent use for task work when the selected skill allows it - cover both absolute-path and aliased-root prompt variants ## Why Partial reads can skip routing and verification requirements later in skill instructions. Delegated summaries can also omit constraints the main agent needs to follow. The existing "Read only enough" wording made both behaviors appear acceptable. ## Impact Agents should follow complete selected skill instructions while continuing to avoid unrelated references, scripts, and assets. Subagents remain available for task execution where permitted. ## Test plan - `just test -p codex-core-skills` (101 passed) - `just fmt` - `git diff --check` --- codex-rs/core-skills/src/render.rs | 36 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 28617fb6c4..1eff1f879f 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -28,16 +28,16 @@ pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The li - Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. - Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. - How to use a skill (progressive disclosure): - 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 1) After deciding to use a skill, the main agent must open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF. 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. - 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it. 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. - Coordination and sequencing: - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. - Context hygiene: - - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets. - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; @@ -45,16 +45,16 @@ pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list abov - Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. - Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. - How to use a skill (progressive disclosure): - 1) After deciding to use a skill, expand the listed short `path` with the matching alias from `### Skill roots`, then open its `SKILL.md`. Read only enough to follow the workflow. + 1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF. 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed. - 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it. 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. - Coordination and sequencing: - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. - Context hygiene: - - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets. - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; @@ -982,6 +982,30 @@ mod tests { ) } + #[test] + fn skill_usage_instructions_require_complete_main_agent_reads() { + for instructions in [ + SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS, + SKILLS_HOW_TO_USE_WITH_ALIASES, + ] { + assert!(instructions.contains("read its `SKILL.md` completely")); + assert!(instructions.contains("continue until EOF")); + assert!(instructions.contains( + "The main agent must read each required instruction or reference file itself" + )); + assert!(instructions.contains( + "Do not delegate reading, summarizing, or interpreting skill instructions" + )); + assert!(instructions.contains( + "Subagents may still perform task work when the selected skill allows it" + )); + assert!(instructions.contains( + "Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file" + )); + assert!(!instructions.contains("Read only enough to follow the workflow")); + } + } + #[test] fn default_budget_uses_two_percent_of_full_context_window() { assert_eq!( From b89d91f6ffee5474f23a97b23b401644c4e087d5 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 8 Jun 2026 14:41:05 -0700 Subject: [PATCH 129/141] feat: support oneOf and allOf in tool input schemas (#24118) ## Why Some connector golden schemas use JSON Schema composition keywords beyond `anyOf`, specifically top-level or nested `oneOf` and `allOf`. Codex currently needs to preserve those shapes when parsing MCP tool input schemas so connector tools do not lose valid schema structure during normalization. To prevent an increased Responses API error rate, this PR will be merged after the Responses API supports top-level `oneOf`/`allOf`. ## What Changed - Adds `oneOf` and `allOf` support to `JsonSchema`, matching the existing `anyOf` handling. - Traverses `oneOf` and `allOf` anywhere schema children are visited, including sanitization, definition reachability, description stripping, and deep schema compaction. - Adds a final large-schema compaction pass that prunes schema objects containing `anyOf`, `oneOf`, or `allOf` to `{}` if earlier compaction passes still leave the schema over budget. ## Validation Golden schema token validation over `2,025` schemas under `golden_schemas`, all parsed successfully. Token count is `o200k_base` over compact JSON from `parse_tool_input_schema`. | Percentile | Before PR | After oneOf/allOf | After pruning | |---|---:|---:|---:| | p0 | 9 | 9 | 9 | | p10 | 63 | 64 | 64 | | p25 | 86 | 87 | 87 | | p50 | 125 | 128 | 128 | | p75 | 203 | 206 | 206 | | p90 | 327 | 333 | 333 | | p95 | 460 | 473 | 473 | | p99 | 763 | 779 | 779 | | max | 891 | 955 | 955 | Totals: | Parser state | Total tokens | |---|---:| | Before PR | 345,713 | | After oneOf/allOf | 352,686 | | After pruning | 352,686 | The pruning column matches the oneOf/allOf column for this corpus because no parsed compact golden schema remains over the `4,000` compact-byte budget after the earlier compaction passes. --- codex-rs/tools/src/json_schema.rs | 65 +++++- codex-rs/tools/src/json_schema_tests.rs | 202 ++++++++++++++++++ .../json_schema_policy/google_drive.json | 13 +- 3 files changed, 270 insertions(+), 10 deletions(-) diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 5352eaa91d..528bb3adf8 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -6,13 +6,15 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; const DEFINITION_TABLE_KEYS: [&str; 2] = ["$defs", "definitions"]; -const SCHEMA_CHILD_KEYS: [&str; 2] = ["items", "anyOf"]; +const SCHEMA_CHILD_KEYS: [&str; 4] = ["items", "anyOf", "oneOf", "allOf"]; +const COMPOSITION_SCHEMA_KEYS: [&str; 3] = ["anyOf", "oneOf", "allOf"]; /// Primitive JSON Schema type names we support in tool definitions. /// /// This mirrors the OpenAI Structured Outputs subset for JSON Schema `type`: /// string, number, boolean, integer, object, array, and null. -/// Keywords such as `enum`, `const`, and `anyOf` are modeled separately. +/// Keywords such as `enum`, `const`, `anyOf`, `oneOf`, and `allOf` are modeled +/// separately. /// See . #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -61,6 +63,10 @@ pub struct JsonSchema { pub additional_properties: Option, #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")] pub any_of: Option>, + #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")] + pub one_of: Option>, + #[serde(rename = "allOf", skip_serializing_if = "Option::is_none")] + pub all_of: Option>, #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")] pub defs: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -85,6 +91,22 @@ impl JsonSchema { } } + pub fn one_of(variants: Vec, description: Option) -> Self { + Self { + description, + one_of: Some(variants), + ..Default::default() + } + } + + pub fn all_of(variants: Vec, description: Option) -> Self { + Self { + description, + all_of: Some(variants), + ..Default::default() + } + } + pub fn boolean(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Boolean, description) } @@ -219,6 +241,7 @@ const LARGE_SCHEMA_COMPACTION_PASSES: &[LargeSchemaCompactionPass] = &[ strip_schema_descriptions, drop_schema_definitions, collapse_deep_schema_objects_from_root, + prune_schema_compositions, ]; fn collapse_deep_schema_objects_from_root(value: &mut JsonValue) { @@ -397,6 +420,27 @@ fn collapse_deep_schema_objects(value: &mut JsonValue, depth: usize) { } } +fn prune_schema_compositions(value: &mut JsonValue) { + match value { + JsonValue::Array(values) => { + for value in values { + prune_schema_compositions(value); + } + } + JsonValue::Object(map) => { + if has_composition_keyword(map) { + *value = json!({}); + return; + } + + for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| { + prune_schema_compositions(value); + }); + } + _ => {} + } +} + fn is_complex_schema_object(map: &serde_json::Map) -> bool { SCHEMA_CHILD_KEYS.iter().any(|key| map.contains_key(*key)) || map.contains_key("properties") @@ -404,10 +448,16 @@ fn is_complex_schema_object(map: &serde_json::Map) -> bool { || map.contains_key("$ref") } +fn has_composition_keyword(map: &serde_json::Map) -> bool { + COMPOSITION_SCHEMA_KEYS + .into_iter() + .any(|key| map.contains_key(key)) +} + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// schema representation. This function: /// - Ensures every typed schema object has a `"type"` when required. -/// - Preserves explicit `anyOf`. +/// - Preserves explicit `anyOf`, `oneOf`, and `allOf`. /// - Preserves `$ref` and reachable local `$defs` / `definitions`. /// - Collapses `const` into single-value `enum`. /// - Fills required child fields for object/array schema types, including @@ -443,8 +493,10 @@ fn sanitize_json_schema(value: &mut JsonValue) { if let Some(value) = map.get_mut("prefixItems") { sanitize_json_schema(value); } - if let Some(value) = map.get_mut("anyOf") { - sanitize_json_schema(value); + for key in COMPOSITION_SCHEMA_KEYS { + if let Some(value) = map.get_mut(key) { + sanitize_json_schema(value); + } } for table in DEFINITION_TABLE_KEYS { sanitize_schema_table(map, table); @@ -456,7 +508,8 @@ fn sanitize_json_schema(value: &mut JsonValue) { let mut schema_types = normalized_schema_types(map); - if schema_types.is_empty() && (map.contains_key("$ref") || map.contains_key("anyOf")) { + if schema_types.is_empty() && (map.contains_key("$ref") || has_composition_keyword(map)) + { return; } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 6973d06c00..1f245c6a6b 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -728,6 +728,109 @@ fn parse_tool_input_schema_preserves_nested_any_of_property() { ); } +#[test] +fn parse_tool_input_schema_preserves_nested_one_of_property() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "query": { + // "oneOf": [ + // { "const": "exact" }, + // { "type": "number" } + // ] + // } + // } + // } + // + // Expected normalization behavior: + // - The nested `oneOf` is preserved. + // - Child variants are recursively sanitized, including `const` to `enum`. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "query": { + "oneOf": [ + { "const": "exact" }, + { "type": "number" } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::object( + BTreeMap::from([( + "query".to_string(), + JsonSchema::one_of( + vec![ + JsonSchema::string_enum( + vec![serde_json::json!("exact")], + /*description*/ None, + ), + JsonSchema::number(/*description*/ None), + ], + /*description*/ None, + ), + )]), + /*required*/ None, + /*additional_properties*/ None + ) + ); +} + +#[test] +fn parse_tool_input_schema_preserves_nested_all_of_property() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "query": { + // "allOf": [ + // { "type": "string" }, + // { "description": "unrecognized by itself" } + // ] + // } + // } + // } + // + // Expected normalization behavior: + // - The nested `allOf` is preserved structurally rather than flattened. + // - Child variants are recursively sanitized. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "query": { + "allOf": [ + { "type": "string" }, + { "description": "unrecognized by itself" } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::object( + BTreeMap::from([( + "query".to_string(), + JsonSchema::all_of( + vec![ + JsonSchema::string(/*description*/ None), + JsonSchema::default(), + ], + /*description*/ None, + ), + )]), + /*required*/ None, + /*additional_properties*/ None + ) + ); +} + #[test] fn parse_tool_input_schema_preserves_type_unions_without_rewriting_to_any_of() { // Example schema shape: @@ -1070,6 +1173,77 @@ fn parse_large_tool_input_schema_strips_descriptions_without_removing_descriptio ); } +#[test] +fn parse_large_tool_input_schema_prunes_compositions_as_last_resort() { + for composition_key in super::COMPOSITION_SCHEMA_KEYS { + let variants = vec![ + serde_json::json!({ + "type": "string", + "enum": ["first ".repeat(400)] + }), + serde_json::json!({ + "type": "string", + "enum": ["second ".repeat(400)] + }), + serde_json::json!({ + "type": "string", + "enum": ["third ".repeat(400)] + }), + ]; + + let mut choice = serde_json::Map::new(); + choice.insert( + composition_key.to_string(), + serde_json::Value::Array(variants), + ); + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "choice": choice + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": {} + } + }) + ); + } +} + +#[test] +fn parse_large_tool_input_schema_prunes_single_composition_variant_if_still_over_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": [ + { + "type": "string", + "enum": ["x".repeat(4_500)] + } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": {} + } + }) + ); +} + #[test] fn parse_large_tool_input_schema_preserves_object_enum_literal_descriptions() { let schema = parse_tool_input_schema(&serde_json::json!({ @@ -1397,10 +1571,24 @@ fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { {"$ref": "#/$defs/Choice"}, {"type": "string"} ] + }, + "exclusive_choice": { + "oneOf": [ + {"$ref": "#/$defs/ExclusiveChoice"}, + {"type": "integer"} + ] + }, + "combined": { + "allOf": [ + {"$ref": "#/$defs/Combined"}, + {"type": "object"} + ] } }, "$defs": { + "Combined": {"type": "object"}, "Choice": {"type": "boolean"}, + "ExclusiveChoice": {"type": "null"}, "Extra": {"type": "number"}, "Item": {"type": "string"}, "Unused": {"type": "null"} @@ -1419,6 +1607,18 @@ fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { {"type": "string"} ] }, + "combined": { + "allOf": [ + {"$ref": "#/$defs/Combined"}, + {"type": "object", "properties": {}} + ] + }, + "exclusive_choice": { + "oneOf": [ + {"$ref": "#/$defs/ExclusiveChoice"}, + {"type": "integer"} + ] + }, "items_holder": { "type": "array", "items": {"$ref": "#/$defs/Item"} @@ -1430,7 +1630,9 @@ fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { } }, "$defs": { + "Combined": {"type": "object", "properties": {}}, "Choice": {"type": "boolean"}, + "ExclusiveChoice": {"type": "null"}, "Extra": {"type": "number"}, "Item": {"type": "string"} } diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json index bf37296cc0..518038aee5 100644 --- a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json @@ -53,13 +53,18 @@ { "pointer": "/properties/title/description", "value": "Copied file title." + }, + { + "pointer": "/properties/file/oneOf/0/type", + "value": "string" + }, + { + "pointer": "/properties/metadata/allOf/0/properties/source/type", + "value": "string" } ], "expected_pruned": [], - "expected_dropped_fields": [ - "/properties/file/oneOf", - "/properties/metadata/allOf" - ] + "expected_dropped_fields": [] } ] } From daf76a57d2564be85b6e34c25a29380b3d4315b4 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 8 Jun 2026 14:46:59 -0700 Subject: [PATCH 130/141] [codex] Prune stale curated plugin caches (#26934) Curated plugin startup refresh now removes cached plugins whose names no longer appear in the raw openai-curated marketplace. This prevents users with the old standalone Google Sheets plugin selected locally from continuing to load its stale cache after the curated repo drops it. Existing config is left untouched, and plugins still present in the marketplace continue to refresh from local curated sources. Validation: - `just fmt` - `just test -p codex-core-plugins` - `git diff --check` --- codex-rs/core-plugins/src/loader.rs | 45 +++++++++++++--------- codex-rs/core-plugins/src/manager_tests.rs | 27 +++++++++++++ codex-rs/core-plugins/src/marketplace.rs | 11 ++++++ 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index d6414be6ff..9dfa0bbc6f 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -5,6 +5,7 @@ use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; +use crate::marketplace::load_raw_marketplace_plugin_names; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; use crate::store::PluginStore; @@ -307,6 +308,10 @@ pub fn refresh_curated_plugin_cache( .join(".agents/plugins/marketplace.json"), ) .map_err(|_| "local curated marketplace is not available".to_string())?; + let marketplace_plugin_names = load_raw_marketplace_plugin_names(&curated_marketplace_path) + .map_err(|err| { + format!("failed to load curated marketplace plugin names for cache refresh: {err}") + })?; let curated_marketplace = load_marketplace(&curated_marketplace_path) .map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?; @@ -321,36 +326,40 @@ pub fn refresh_curated_plugin_cache( ); continue; } - let source_path = match plugin.source { - MarketplacePluginSource::Local { path } => path, - MarketplacePluginSource::Git { .. } => { - warn!( - plugin = plugin_name, - marketplace = OPENAI_CURATED_MARKETPLACE_NAME, - "skipping remote curated plugin source during cache refresh" - ); - continue; - } - }; - plugin_sources.insert(plugin_name, source_path); + if let MarketplacePluginSource::Local { path } = plugin.source { + plugin_sources.insert(plugin_name, path); + } } let mut cache_refreshed = false; for plugin_id in configured_curated_plugin_ids { - if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) - { - continue; - } - - let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { + if !marketplace_plugin_names.contains(&plugin_id.plugin_name) { warn!( plugin = plugin_id.plugin_name, marketplace = OPENAI_CURATED_MARKETPLACE_NAME, "configured curated plugin no longer exists in curated marketplace during cache refresh" ); + if store.plugin_base_root(plugin_id).as_path().exists() { + store.uninstall(plugin_id).map_err(|err| { + format!( + "failed to remove stale curated plugin cache for {}: {err}", + plugin_id.as_key() + ) + })?; + cache_refreshed = true; + } + continue; + } + + let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { continue; }; + if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) + { + continue; + } + store .install_with_version(source_path, plugin_id.clone(), cache_plugin_version.clone()) .map_err(|err| { diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index b1be37655b..4fc8456ce2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3039,6 +3039,33 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren ); } +#[test] +fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &[]); + let plugin_id = PluginId::new( + "google-sheets".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + let plugin_cache_root = tmp + .path() + .join("plugins/cache/openai-curated/google-sheets"); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + &format!("google-sheets/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), + "google-sheets", + ); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should remove stale configured plugin") + ); + + assert!(!plugin_cache_root.exists()); +} + #[test] fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 49925a673f..049d882751 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde_json::Value as JsonValue; +use std::collections::HashSet; use std::fs; use std::io; use std::path::Component; @@ -326,6 +327,16 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result Result, MarketplaceError> { + Ok(load_raw_marketplace_manifest(path)? + .plugins + .into_iter() + .map(|plugin| plugin.name) + .collect()) +} + #[doc(hidden)] pub fn list_marketplaces_with_home( additional_roots: &[AbsolutePathBuf], From 381f0de531e0bc7759863295fc333dd0087b4faf Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 8 Jun 2026 14:47:09 -0700 Subject: [PATCH 131/141] Use cached remote plugin catalog for plugin list (#26932) ## Summary This changes the default remote plugin marketplace listing to use the cached global remote catalog when it is already present on disk. The foreground `plugin/list` response can then return from the local catalog cache instead of waiting on `/ps/plugins/list`. When a cached global catalog was present at the start of the request, `plugin/list` still schedules a background refresh through the existing plugin-list background task path so the disk cache is updated for future requests. Cache misses keep the existing synchronous remote fetch path and write the cache, and they do not schedule an extra duplicate background `/ps/plugins/list` refresh. Installed/enabled state continues to come from the existing remote installed overlay path. This change only affects the global remote catalog directory data used by `plugin/list`. ## Testing - `just fmt` - `just test -p codex-app-server plugin_list_uses_cached_global_remote_catalog_and_refreshes_it` - `just test -p codex-core-plugins` - `git diff --check` --- .../src/request_processors/plugins.rs | 49 +++-- .../app-server/tests/suite/v2/plugin_list.rs | 179 ++++++++++++++++++ codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/manager.rs | 104 ++++++++++ codex-rs/core-plugins/src/remote.rs | 30 +++ 5 files changed, 345 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 79791c142a..77040905c0 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::PluginListBackgroundTaskOptions; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::RemotePluginScope; @@ -543,21 +544,30 @@ impl PluginRequestProcessor { return Ok(empty_response()); } let plugins_input = config.plugins_config_input(); - if include_local || marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { - plugins_manager.maybe_start_plugin_list_background_tasks_for_config( - &plugins_input, - auth.clone(), - &roots, - Some(self.effective_plugins_changed_callback()), + let include_shared_with_me = + marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe); + let include_global_remote = + !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let refresh_global_remote_catalog_cache = include_global_remote + && codex_core_plugins::remote::has_cached_global_remote_plugin_catalog( + config.codex_home.as_path(), + &remote_plugin_service_config, + auth.as_ref(), ); - } let (mut data, marketplace_load_errors) = if include_local { let config_for_marketplace_listing = plugins_input.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); + let roots_for_marketplace_listing = roots.clone(); let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?; match tokio::task::spawn_blocking(move || { let outcome = plugins_manager_for_marketplace_listing - .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + .list_marketplaces_for_config( + &config_for_marketplace_listing, + &roots_for_marketplace_listing, + )?; Ok::< ( Vec, @@ -617,9 +627,6 @@ impl PluginRequestProcessor { // TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are // served directly from the normal remote catalog. if include_vertical && !config.features.enabled(Feature::RemotePlugin) { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace( &remote_plugin_service_config, auth.as_ref(), @@ -644,21 +651,16 @@ impl PluginRequestProcessor { } let mut remote_sources = Vec::new(); - if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { + if include_global_remote { remote_sources.push(RemoteMarketplaceSource::Global); } if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); } - if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) - && config.features.enabled(Feature::PluginSharing) - { + if include_shared_with_me && config.features.enabled(Feature::PluginSharing) { remote_sources.push(RemoteMarketplaceSource::SharedWithMe); } if !remote_sources.is_empty() { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; match codex_core_plugins::remote::fetch_remote_marketplaces( &remote_plugin_service_config, auth.as_ref(), @@ -702,6 +704,17 @@ impl PluginRequestProcessor { } } } + if include_local || include_shared_with_me || include_global_remote { + plugins_manager.maybe_start_plugin_list_background_tasks_for_config( + &plugins_input, + auth.clone(), + &roots, + PluginListBackgroundTaskOptions { + refresh_global_remote_catalog_cache, + }, + Some(self.effective_plugins_changed_callback()), + ); + } let featured_plugin_ids = if data .iter() diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index d9a0a00493..418f071e9e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1863,6 +1863,102 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Ok(()) } +#[tokio::test] +async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000"; + let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111"; + let cached_body = + remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work"); + let refreshed_body = remote_plugin_list_body( + refreshed_remote_plugin_id, + "notion", + "Notion", + "Capture notes", + ); + mount_remote_plugin_list(&server, "GLOBAL", &cached_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: PluginListResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + let remote_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected warmed remote marketplace"); + assert_eq!( + remote_marketplace.plugins[0].id, + "linear@openai-curated-remote" + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?; + wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id]) + .await?; + + server.reset().await; + mount_remote_plugin_list(&server, "GLOBAL", &refreshed_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: PluginListResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + let remote_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected cached remote marketplace"); + assert_eq!( + remote_marketplace.plugins[0].id, + "linear@openai-curated-remote" + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?; + wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id]) + .await?; + + Ok(()) +} + #[tokio::test] async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> { let codex_home = TempDir::new()?; @@ -3025,6 +3121,52 @@ async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &st Ok(()) } +async fn wait_for_cached_remote_catalog_plugin_ids( + codex_home: &std::path::Path, + expected_plugin_ids: &[&str], +) -> Result<()> { + let mut expected_plugin_ids = expected_plugin_ids + .iter() + .copied() + .map(str::to_string) + .collect::>(); + expected_plugin_ids.sort(); + timeout(DEFAULT_TIMEOUT, async { + loop { + let plugin_ids = cached_remote_catalog_plugin_ids(codex_home)?; + if plugin_ids == expected_plugin_ids { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +fn cached_remote_catalog_plugin_ids(codex_home: &std::path::Path) -> Result> { + let cache_dir = codex_home.join("cache/remote_plugin_catalog"); + if !cache_dir.exists() { + return Ok(Vec::new()); + } + let mut plugin_ids = Vec::new(); + for entry in std::fs::read_dir(cache_dir)? { + let path = entry?.path(); + let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?; + let Some(plugins) = cached_catalog["plugins"].as_array() else { + continue; + }; + plugin_ids.extend( + plugins + .iter() + .filter_map(|plugin| plugin["id"].as_str()) + .map(str::to_string), + ); + } + plugin_ids.sort(); + Ok(plugin_ids) +} + async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { timeout(DEFAULT_TIMEOUT, async { loop { @@ -3063,6 +3205,43 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) .await; } +fn remote_plugin_list_body( + remote_plugin_id: &str, + plugin_name: &str, + display_name: &str, + short_description: &str, +) -> String { + format!( + r#"{{ + "plugins": [ + {{ + "id": "{remote_plugin_id}", + "name": "{plugin_name}", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": {{ + "version": "1.2.3", + "display_name": "{display_name}", + "description": "{display_name}", + "app_ids": [], + "interface": {{ + "short_description": "{short_description}", + "capabilities": ["Read"] + }}, + "skills": [] + }} + }} + ], + "pagination": {{ + "limit": 50, + "next_page_token": null + }} +}}"# + ) +} + async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/list")) diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 8cfb9092f6..84eeea5dc9 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -34,6 +34,7 @@ pub use manager::PluginDetailsUnavailableReason; pub use manager::PluginInstallError; pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; +pub use manager::PluginListBackgroundTaskOptions; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; pub use manager::PluginUninstallError; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 1839b06e02..59cfd135ed 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -146,6 +146,22 @@ struct RemoteInstalledPluginsCacheRefreshState { in_flight: bool, } +struct GlobalRemoteCatalogCacheRefreshRequest { + service_config: RemotePluginServiceConfig, + auth: Option, +} + +#[derive(Default)] +struct GlobalRemoteCatalogCacheRefreshState { + requested: Option, + in_flight: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PluginListBackgroundTaskOptions { + pub refresh_global_remote_catalog_cache: bool, +} + #[derive(Clone, PartialEq, Eq)] struct NonCuratedCacheRefreshRequest { roots: Vec, @@ -302,6 +318,7 @@ pub struct PluginsManager { enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, + global_remote_catalog_cache_refresh_state: RwLock, restriction_product: Option, analytics_events_client: RwLock>, } @@ -355,6 +372,9 @@ impl PluginsManager { remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), + global_remote_catalog_cache_refresh_state: RwLock::new( + GlobalRemoteCatalogCacheRefreshState::default(), + ), restriction_product, analytics_events_client: RwLock::new(None), } @@ -702,14 +722,33 @@ impl PluginsManager { ); } + fn maybe_start_global_remote_catalog_cache_refresh( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + ) { + if !config.plugins_enabled || !config.remote_plugin_enabled { + return; + } + + self.schedule_global_remote_catalog_cache_refresh(GlobalRemoteCatalogCacheRefreshRequest { + service_config: remote_plugin_service_config(config), + auth, + }); + } + pub fn maybe_start_plugin_list_background_tasks_for_config( self: &Arc, config: &PluginsConfigInput, auth: Option, roots: &[AbsolutePathBuf], + options: PluginListBackgroundTaskOptions, on_effective_plugins_changed: Option>, ) { self.maybe_start_non_curated_plugin_cache_refresh(roots); + if options.refresh_global_remote_catalog_cache { + self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone()); + } self.maybe_start_remote_installed_plugins_cache_refresh( config, auth.clone(), @@ -1447,6 +1486,33 @@ impl PluginsManager { }); } + fn schedule_global_remote_catalog_cache_refresh( + self: &Arc, + request: GlobalRemoteCatalogCacheRefreshRequest, + ) { + let should_spawn = { + let mut state = match self.global_remote_catalog_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.requested = Some(request); + if state.in_flight { + false + } else { + state.in_flight = true; + true + } + }; + if !should_spawn { + return; + } + + let manager = Arc::clone(self); + tokio::spawn(async move { + manager.run_global_remote_catalog_cache_refresh_loop().await; + }); + } + fn schedule_non_curated_plugin_cache_refresh( self: &Arc, roots: &[AbsolutePathBuf], @@ -1613,6 +1679,44 @@ impl PluginsManager { } } + async fn run_global_remote_catalog_cache_refresh_loop(self: Arc) { + loop { + let request = { + let mut state = match self.global_remote_catalog_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + match state.requested.take() { + Some(request) => request, + None => { + state.in_flight = false; + return; + } + } + }; + + match crate::remote::fetch_and_cache_global_remote_plugin_catalog( + self.codex_home.as_path(), + &request.service_config, + request.auth.as_ref(), + ) + .await + { + Ok(()) => {} + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "failed to refresh cached global remote plugin catalog" + ); + } + } + } + } + fn run_non_curated_plugin_cache_refresh_loop(self: Arc) { loop { let request = { diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index b96746b637..332546fdc9 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -576,6 +576,25 @@ pub async fn fetch_remote_marketplaces( match source { RemoteMarketplaceSource::Global => { let scope = RemotePluginScope::Global; + if let Some(codex_home) = global_catalog_cache_path + && let Some(directory_plugins) = + catalog_cache::load_cached_global_directory_plugins( + codex_home, config, auth, + ) + { + let installed_plugins = + fetch_installed_plugins_for_scope(config, auth, scope).await?; + if let Some(marketplace) = build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + installed_plugins, + /*include_installed_only*/ true, + )? { + marketplaces.push(marketplace); + } + continue; + } let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), @@ -692,6 +711,17 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog( Ok(()) } +pub fn has_cached_global_remote_plugin_catalog( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> bool { + let Ok(auth) = ensure_chatgpt_auth(auth) else { + return false; + }; + catalog_cache::load_cached_global_directory_plugins(codex_home, config, auth).is_some() +} + pub fn cached_global_remote_discoverable_plugins( codex_home: &Path, config: &RemotePluginServiceConfig, From feca160da47b678b73b33dd8a08e010e86b81786 Mon Sep 17 00:00:00 2001 From: richardopenai Date: Mon, 8 Jun 2026 15:29:51 -0700 Subject: [PATCH 132/141] [codex] Add OTEL counter descriptions (#26091) ## Why Metric descriptions should be declared with reusable OTEL instruments instead of being coupled to individual consumers. Counter descriptions are the smallest API primitive needed by the exec-server observability work. ## What changed - Adds `counter_with_description` while preserving the existing counter API. - Caches counters by name and description so instrument metadata remains part of the declaration identity. - Covers the exported description together with the existing value and attribute contract. This PR only adds counter descriptions. It does not add gauges, second-based durations, or exec-server adoption. ## Stack 1. **#26091: counter descriptions** 2. #27057: gauge instruments 3. #27058: second-based duration histograms Related independent coverage: #27059 tests OTLP HTTP log and trace event export. The `codex-exec-server` bounded service tag now stays with the exec-server adoption change instead of this reusable infrastructure stack. ## Validation - `just test -p codex-otel` - `just fix -p codex-otel` - `just fmt` --- codex-rs/otel/src/metrics/client.rs | 43 +++++++++++++++++++++++++---- codex-rs/otel/tests/suite/send.rs | 4 ++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/codex-rs/otel/src/metrics/client.rs b/codex-rs/otel/src/metrics/client.rs index 417c1f4bd9..9d5ad5d166 100644 --- a/codex-rs/otel/src/metrics/client.rs +++ b/codex-rs/otel/src/metrics/client.rs @@ -45,6 +45,12 @@ const METER_NAME: &str = "codex"; const DURATION_UNIT: &str = "ms"; const DURATION_DESCRIPTION: &str = "Duration in milliseconds."; +#[derive(Debug, Eq, Hash, PartialEq)] +struct InstrumentKey { + name: String, + description: Option, +} + #[derive(Clone, Debug)] struct SharedManualReader { inner: Arc, @@ -82,7 +88,7 @@ impl MetricReader for SharedManualReader { struct MetricsClientInner { meter_provider: SdkMeterProvider, meter: Meter, - counters: Mutex>>, + counters: Mutex>>, histograms: Mutex>>, duration_histograms: Mutex>>, runtime_reader: Option>, @@ -90,7 +96,13 @@ struct MetricsClientInner { } impl MetricsClientInner { - fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) -> Result<()> { + fn counter( + &self, + name: &str, + description: Option<&str>, + inc: i64, + tags: &[(&str, &str)], + ) -> Result<()> { validate_metric_name(name)?; if inc < 0 { return Err(MetricsError::NegativeCounterIncrement { @@ -104,9 +116,17 @@ impl MetricsClientInner { .counters .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let counter = counters - .entry(name.to_string()) - .or_insert_with(|| self.meter.u64_counter(name.to_string()).build()); + let key = InstrumentKey { + name: name.to_string(), + description: description.map(str::to_string), + }; + let counter = counters.entry(key).or_insert_with(|| { + let builder = self.meter.u64_counter(name.to_string()); + match description { + Some(description) => builder.with_description(description.to_string()).build(), + None => builder.build(), + } + }); counter.add(inc as u64, &attributes); Ok(()) } @@ -242,7 +262,18 @@ impl MetricsClient { /// Send a single counter increment. pub fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) -> Result<()> { - self.0.counter(name, inc, tags) + self.0.counter(name, /*description*/ None, inc, tags) + } + + /// Send a single counter increment with an instrument description. + pub fn counter_with_description( + &self, + name: &str, + description: &str, + inc: i64, + tags: &[(&str, &str)], + ) -> Result<()> { + self.0.counter(name, Some(description), inc, tags) } /// Send a single histogram sample. diff --git a/codex-rs/otel/tests/suite/send.rs b/codex-rs/otel/tests/suite/send.rs index fc382bf88a..3a268d3c7d 100644 --- a/codex-rs/otel/tests/suite/send.rs +++ b/codex-rs/otel/tests/suite/send.rs @@ -13,8 +13,9 @@ fn send_builds_payload_with_tags_and_histograms() -> Result<()> { let (metrics, exporter) = build_metrics_with_defaults(&[("service", "codex-cli"), ("env", "prod")])?; - metrics.counter( + metrics.counter_with_description( "codex.turns", + "Total number of Codex turns.", /*inc*/ 1, &[("model", "gpt-5.1"), ("env", "dev")], )?; @@ -28,6 +29,7 @@ fn send_builds_payload_with_tags_and_histograms() -> Result<()> { let resource_metrics = latest_metrics(&exporter); let counter = find_metric(&resource_metrics, "codex.turns").expect("counter metric missing"); + assert_eq!(counter.description(), "Total number of Codex turns."); let counter_attributes = match counter.data() { opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data { opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => { From 0473a5cc522cd8d0a798595c0a08ce661eb2a0ab Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 8 Jun 2026 15:43:08 -0700 Subject: [PATCH 133/141] feat(doctor): report editor and pager environment (#27081) ## Background This was prompted by [#26858](https://github.com/openai/codex/issues/26858), where the attached doctor report did not include the editor selection and I had to [ask which editor was in use](https://github.com/openai/codex/issues/26858#issuecomment-4653829891) before investigating the external-editor newline issue. Capturing these variables in doctor makes that context available up front in future reports. `codex doctor` is intended to capture enough local context to diagnose startup and terminal behavior, but it did not report the environment variables that select an external editor or configure command pagers. The TUI [prefers `VISUAL` over `EDITOR`](https://github.com/openai/codex/blob/56554904babcaacf4444a2cc90716880837dff7c/codex-rs/tui/src/external_editor.rs#L31-L38), so missing or unexpected values can explain why the external-editor shortcut fails or launches the wrong command. Pager values are also useful inherited-shell context even though [unified exec normalizes its effective pager variables to `cat`](https://github.com/openai/codex/blob/56554904babcaacf4444a2cc90716880837dff7c/codex-rs/core/src/unified_exec/process_manager.rs#L60-L70). These variables can contain arbitrary command arguments or inline environment assignments. The human report is local, but `codex doctor --json` may be attached to feedback, so the machine-readable report should not include their raw contents. ## What Changed - Report `VISUAL` and `EDITOR` in the system environment details, using `not set` when either variable is absent. - Report inherited `PAGER`, `GIT_PAGER`, `GH_PAGER`, and `LESS` values when present. - Preserve full values in local human output while reducing these fields to `set` or `not set` in redacted JSON output. - Add structured check, JSON-redaction, rendered-output, and snapshot coverage. ## How to Test 1. From `codex-rs`, run Codex with explicit editor and pager variables: ```sh env VISUAL='code --wait' EDITOR=vim PAGER='less -R' GIT_PAGER=delta GH_PAGER=less LESS=-FRX \ cargo run -p codex-cli --bin codex -- doctor --no-color ``` 2. Confirm the `system` details show the full values for all six variables. 3. Unset the pager variables and rerun the command. Confirm pager rows are omitted while missing editor variables are shown as `not set`. 4. Run the same configured environment with `doctor --json`. Confirm each configured editor or pager field is reported as `set` and none of the raw commands or arguments appear in the JSON. Targeted tests: - `just test -p codex-cli` (279 tests passed) --- codex-rs/cli/src/doctor.rs | 56 ++++++++++++- codex-rs/cli/src/doctor/output.rs | 14 +++- codex-rs/cli/src/doctor/output/detail.rs | 12 +++ ..._doctor_human_report_environment_rows.snap | 6 ++ codex-rs/cli/src/doctor/system.rs | 84 +++++++++++++++++-- 5 files changed, 163 insertions(+), 9 deletions(-) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index dde6fad470..d817f1a192 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -682,7 +682,7 @@ fn structured_json_details(details: &[String]) -> (BTreeMap existing.push(value), None => { @@ -693,6 +693,21 @@ fn structured_json_details(details: &[String]) -> (BTreeMap String { + if matches!( + key, + "VISUAL" | "EDITOR" | "PAGER" | "GIT_PAGER" | "GH_PAGER" | "LESS" + ) && !value.eq_ignore_ascii_case("not set") + { + // Editor and pager configuration can contain arbitrary arguments or + // inline environment assignments. Keep full values local to human output + // because the JSON report may be attached to feedback. + "set".to_string() + } else { + value.to_string() + } +} + fn run_sync_check( label: &'static str, progress: Arc, @@ -3226,6 +3241,18 @@ mod tests { overall_status: CheckStatus::Warning, codex_version: "0.0.0".to_string(), checks: vec![ + DoctorCheck::new( + "system.environment", + "system", + CheckStatus::Ok, + "OS language en-US", + ) + .detail("VISUAL: code --wait") + .detail("EDITOR: env AWS_ACCESS_KEY_ID=AKIAEXAMPLE vim") + .detail("PAGER: env PRIVATE_PAGER_VALUE=pager-secret less") + .detail("GIT_PAGER: delta") + .detail("GH_PAGER: less") + .detail("LESS: -FRX"), DoctorCheck::new( "mcp.config", "mcp", @@ -3260,8 +3287,35 @@ mod tests { assert!(!redacted.contains("user:pass")); assert!(!redacted.contains("x=abc")); assert!(!redacted.contains("sk-live-secret")); + assert!(!redacted.contains("AKIAEXAMPLE")); + assert!(!redacted.contains("pager-secret")); + assert!(!redacted.contains("code --wait")); assert!(redacted.contains("https://example.com/mcp")); assert_eq!(json["checks"].is_object(), true); + assert_eq!( + json["checks"]["system.environment"]["details"]["VISUAL"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["EDITOR"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["PAGER"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["GIT_PAGER"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["GH_PAGER"], + "set" + ); + assert_eq!( + json["checks"]["system.environment"]["details"]["LESS"], + "set" + ); assert_eq!(json["checks"]["mcp.config"]["id"], "mcp.config"); assert_eq!( json["checks"]["mcp.config"]["details"]["OPENAI_API_KEY"], diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index 3f6e2f05d2..81f9231482 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -1133,7 +1133,13 @@ mod tests { "OS language en-US", ) .detail("os: macOS 15.0") - .detail("os language: en-US"), + .detail("os language: en-US") + .detail("VISUAL: code --wait") + .detail("EDITOR: vim") + .detail("PAGER: less -R") + .detail("GIT_PAGER: delta") + .detail("GH_PAGER: less") + .detail("LESS: -FRX"), DoctorCheck::new( "runtime.provenance", "runtime", @@ -1246,6 +1252,12 @@ Environment ✓ system en-US os macOS 15.0 OS language en-US + VISUAL code --wait + EDITOR vim + PAGER less -R + GIT_PAGER delta + GH_PAGER less + LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent managed by npm: no · bun: no · package root — diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 6c6cf8633c..05e7af404e 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -62,6 +62,12 @@ fn system_details(parsed: &[ParsedDetail]) -> Vec { push_row_if_present(&mut out, parsed, "LC_ALL", "LC_ALL"); push_row_if_present(&mut out, parsed, "LC_CTYPE", "LC_CTYPE"); push_row_if_present(&mut out, parsed, "LANG", "LANG"); + push_row_if_present(&mut out, parsed, "VISUAL", "VISUAL"); + push_row_if_present(&mut out, parsed, "EDITOR", "EDITOR"); + push_row_if_present(&mut out, parsed, "PAGER", "PAGER"); + push_row_if_present(&mut out, parsed, "GIT_PAGER", "GIT_PAGER"); + push_row_if_present(&mut out, parsed, "GH_PAGER", "GH_PAGER"); + push_row_if_present(&mut out, parsed, "LESS", "LESS"); push_remaining( &mut out, parsed, @@ -73,6 +79,12 @@ fn system_details(parsed: &[ParsedDetail]) -> Vec { "LC_ALL", "LC_CTYPE", "LANG", + "VISUAL", + "EDITOR", + "PAGER", + "GIT_PAGER", + "GH_PAGER", + "LESS", ], &[], ); diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index 84d3441fb2..b3c8d1ee78 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -13,6 +13,12 @@ Environment ✓ system en-US os macOS 15.0 OS language en-US + VISUAL code --wait + EDITOR vim + PAGER less -R + GIT_PAGER delta + GH_PAGER less + LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent managed by npm: no · bun: no · package root — diff --git a/codex-rs/cli/src/doctor/system.rs b/codex-rs/cli/src/doctor/system.rs index c72b76561d..cbab5aa1bf 100644 --- a/codex-rs/cli/src/doctor/system.rs +++ b/codex-rs/cli/src/doctor/system.rs @@ -4,6 +4,9 @@ use std::env; use super::DoctorCheck; use super::LOCALE_ENV_VARS; +const EDITOR_ENV_VARS: &[&str] = &["VISUAL", "EDITOR"]; +const PAGER_ENV_VARS: &[&str] = &["PAGER", "GIT_PAGER", "GH_PAGER", "LESS"]; + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct SystemCheckInputs { os: String, @@ -11,6 +14,8 @@ struct SystemCheckInputs { os_version: String, os_language: Option, locale_env: BTreeMap, + editor_env: BTreeMap, + pager_env: BTreeMap, } impl SystemCheckInputs { @@ -24,12 +29,30 @@ impl SystemCheckInputs { .map(|value| ((*name).to_string(), value)) }) .collect(); + let editor_env = EDITOR_ENV_VARS + .iter() + .map(|name| { + let value = env::var_os(name) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "not set".to_string()); + ((*name).to_string(), value) + }) + .collect(); + let pager_env = PAGER_ENV_VARS + .iter() + .filter_map(|name| { + env::var_os(name) + .map(|value| ((*name).to_string(), value.to_string_lossy().into_owned())) + }) + .collect(); Self { os: info.to_string(), os_type: info.os_type().to_string(), os_version: info.version().to_string(), os_language: sys_locale::get_locale(), locale_env, + editor_env, + pager_env, } } } @@ -54,6 +77,16 @@ fn system_check_from_inputs(inputs: SystemCheckInputs) -> DoctorCheck { details.push(format!("{name}: {value}")); } } + for name in EDITOR_ENV_VARS { + if let Some(value) = inputs.editor_env.get(*name) { + details.push(format!("{name}: {value}")); + } + } + for name in PAGER_ENV_VARS { + if let Some(value) = inputs.pager_env.get(*name) { + details.push(format!("{name}: {value}")); + } + } let summary = inputs .os_language @@ -76,20 +109,46 @@ mod tests { use super::*; #[test] - fn system_check_reports_os_language_and_locale_env() { + fn system_check_reports_os_language_locale_editor_and_pager_env() { let mut locale_env = BTreeMap::new(); locale_env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); + let editor_env = BTreeMap::from([ + ("EDITOR".to_string(), "vim".to_string()), + ("VISUAL".to_string(), "code --wait".to_string()), + ]); + let pager_env = BTreeMap::from([ + ("GH_PAGER".to_string(), "less".to_string()), + ("GIT_PAGER".to_string(), "delta".to_string()), + ("LESS".to_string(), "-FRX".to_string()), + ("PAGER".to_string(), "less -R".to_string()), + ]); let check = system_check_from_inputs(SystemCheckInputs { os: "macOS 15.0".to_string(), os_type: "macos".to_string(), os_version: "15.0".to_string(), os_language: Some("en-US".to_string()), locale_env, + editor_env, + pager_env, }); assert_eq!(check.summary, "OS language en-US"); - assert!(check.details.contains(&"os language: en-US".to_string())); - assert!(check.details.contains(&"LANG: en_US.UTF-8".to_string())); + assert_eq!( + check.details, + vec![ + "os: macOS 15.0", + "os type: macos", + "os version: 15.0", + "os language: en-US", + "LANG: en_US.UTF-8", + "VISUAL: code --wait", + "EDITOR: vim", + "PAGER: less -R", + "GIT_PAGER: delta", + "GH_PAGER: less", + "LESS: -FRX", + ] + ); } #[test] @@ -100,13 +159,24 @@ mod tests { os_version: "unknown".to_string(), os_language: None, locale_env: BTreeMap::new(), + editor_env: BTreeMap::from([ + ("EDITOR".to_string(), "not set".to_string()), + ("VISUAL".to_string(), "not set".to_string()), + ]), + pager_env: BTreeMap::new(), }); assert_eq!(check.summary, "OS language unavailable"); - assert!( - check - .details - .contains(&"os language: unavailable".to_string()) + assert_eq!( + check.details, + vec![ + "os: Linux", + "os type: linux", + "os version: unknown", + "os language: unavailable", + "VISUAL: not set", + "EDITOR: not set", + ] ); } } From 6042e5810e7062cc937bb0397afaa5bea431fbc4 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 8 Jun 2026 16:07:56 -0700 Subject: [PATCH 134/141] chore: preserve one more schema layer during large tool compaction (#27084) ## Summary Some customer MCP tools expose large input schemas that exceed Codex's compact schema budget even after description stripping. Today, the final compaction pass collapses complex schemas starting at depth 2, which can erase important shallow call structure such as small `anyOf` branches, required fields, and help-mode entry points. In one reported case, this degraded a tool schema into `query: any | any`, leaving the model without enough structure to discover the required help call. This change raises the deep-schema collapse boundary from depth 2 to depth 3. That preserves one additional layer of the tool contract while still collapsing deeper expensive subtrees to `{}` when a schema remains over budget. ## What Changed - Increased `MAX_COMPACT_TOOL_SCHEMA_DEPTH` from `2` to `3`. - Updated the schema compaction traversal test to assert the new collapse boundary. - The resulting compacted shape keeps useful shallow structure, for example: - top-level argument names - shallow `anyOf` branches - required object fields - nested property names one level deeper than before ## Validation - Ran `just test -p codex-tools`: 81 tests passed. - Ran a golden schema corpus comparison over 214 discovered tool input schemas under `golden_schemas/*/mcp_tools/*/input_schema.json`. - Depth 2 and depth 3 had identical percentile token counts across the corpus. - Both ended with `0 / 214` schemas over 1k tokens. - Both ended with `0 / 214` schemas over the 4,000-byte compact JSON budget. - Only one golden schema changed, increasing from 49 to 56 tokens, so this does not appear to introduce a meaningful corpus-wide regression. Corpus percentile results: | Percentile | Depth 2 | Depth 3 | |---|---:|---:| | p0 | 9 | 9 | | p10 | 31 | 31 | | p25 | 54 | 54 | | p50 | 81 | 81 | | p75 | 143 | 143 | | p90 | 290 | 290 | | p95 | 431 | 431 | | p99 | 600 | 600 | | max | 832 | 832 | --- codex-rs/tools/src/json_schema.rs | 2 +- codex-rs/tools/src/json_schema_tests.rs | 56 +++++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 528bb3adf8..acffb78b11 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -220,7 +220,7 @@ fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result Date: Mon, 8 Jun 2026 16:33:41 -0700 Subject: [PATCH 135/141] Add typed file URIs (#26840) ## Why Codex needs stable `file:` URI identifiers that can cross process and operating-system boundaries without eagerly interpreting them as native paths. Existing fields also need to keep accepting absolute path strings during migration. ## What changed - Add `codex-utils-path-uri` with a validated, immutable `PathUri` wrapper that currently accepts only `file:` URLs. - Expose URI-level `basename`, `parent`, and `join` operations that preserve authorities and percent encoding without guessing the source operating system. - Keep native conversion explicit through `AbsolutePathBuf` and the current host rules. - Serialize as canonical URI text while accepting both URI text and legacy absolute native paths during deserialization. - Add adversarial coverage for Windows-looking and POSIX paths, UNC authorities, encoded metadata characters, non-UTF-8 POSIX paths, URI hierarchy operations, and legacy serde round trips. --- .github/CODEOWNERS | 1 + codex-rs/Cargo.lock | 15 ++ codex-rs/Cargo.toml | 1 + codex-rs/utils/path-uri/BUILD.bazel | 6 + codex-rs/utils/path-uri/Cargo.toml | 24 ++ codex-rs/utils/path-uri/src/lib.rs | 327 +++++++++++++++++++++++++ codex-rs/utils/path-uri/src/tests.rs | 343 +++++++++++++++++++++++++++ 7 files changed, 717 insertions(+) create mode 100644 codex-rs/utils/path-uri/BUILD.bazel create mode 100644 codex-rs/utils/path-uri/Cargo.toml create mode 100644 codex-rs/utils/path-uri/src/lib.rs create mode 100644 codex-rs/utils/path-uri/src/tests.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d29c06e6f0..48674d3968 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,6 +2,7 @@ /codex-rs/core/ @openai/codex-core-agent-team /codex-rs/ext/extension-api/ @openai/codex-core-agent-team /codex-rs/prompts/ @openai/codex-core-agent-team +/codex-rs/utils/path-uri/ @openai/codex-core-agent-team # Keep macOS AKV signing changes reviewed by Codex maintainers. /.github/actions/setup-akv-pkcs11-codesigning/ @openai/codex-core-agent-team diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8b62cb5210..3d8086c27b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4152,6 +4152,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-utils-path-uri" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", + "ts-rs", + "url", + "urlencoding", +] + [[package]] name = "codex-utils-plugins" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3bb23ba80a..c72b7c7844 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -83,6 +83,7 @@ members = [ "tools", "v8-poc", "utils/absolute-path", + "utils/path-uri", "utils/cargo-bin", "git-utils", "utils/cache", diff --git a/codex-rs/utils/path-uri/BUILD.bazel b/codex-rs/utils/path-uri/BUILD.bazel new file mode 100644 index 0000000000..7bfd2c4542 --- /dev/null +++ b/codex-rs/utils/path-uri/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "path-uri", + crate_name = "codex_utils_path_uri", +) diff --git a/codex-rs/utils/path-uri/Cargo.toml b/codex-rs/utils/path-uri/Cargo.toml new file mode 100644 index 0000000000..1cf70708e7 --- /dev/null +++ b/codex-rs/utils/path-uri/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-utils-path-uri" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +ts-rs = { workspace = true, features = ["no-serde-warnings"] } +url = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs new file mode 100644 index 0000000000..a788fdc9b4 --- /dev/null +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -0,0 +1,327 @@ +//! Typed, immutable `file:` URIs with cross-platform path inspection. +//! +//! See [`PathUri`] for scheme, normalization, and serialization behavior. + +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; +use std::fmt; +use std::str::FromStr; +use thiserror::Error; +use ts_rs::TS; +use url::Url; + +pub const FILE_SCHEME: &str = "file"; + +/// An immutable, cross-platform representation of a `file:` URI. +/// +/// Only the `file:` scheme is currently accepted. Construction validates the +/// URL, and the URI cannot be mutated after construction. [`Self::basename`], +/// [`Self::parent`], and [`Self::join`] operate on URI path segments without +/// interpreting them using the operating system running Codex. +/// +/// `file:` paths retain their URI spelling so they can be parsed independently +/// of the current host. In particular, `/C:/src` remains ambiguous between a +/// Windows drive path and a valid POSIX path until [`Self::to_native_path`] +/// applies the current host's rules. A local POSIX `file:` URI can also retain +/// percent-encoded non-UTF-8 bytes for lossless native round trips. +/// +/// Like [VS Code resources], path operations use `/` URI separators on every +/// host. They preserve a URL authority but do not infer Windows drive or UNC +/// roots from path text. Native path normalization, filesystem aliases, +/// symlinks, case sensitivity, and Unicode normalization are not resolved. +/// +/// Serde represents a `PathUri` as its canonical URI string. Deserialization +/// also accepts an absolute native path for compatibility with fields that +/// previously used [`AbsolutePathBuf`]; relative paths are rejected. Valid +/// `file:` strings round-trip through their canonical URL form, including +/// encoded non-UTF-8 path bytes, but conversion to a native path remains +/// host-dependent as described by [RFC 8089]. +/// +/// [RFC 8089]: https://www.rfc-editor.org/rfc/rfc8089.html +/// [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts +#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)] +#[ts(type = "string")] +pub struct PathUri(Url); + +impl PathUri { + /// Parses and validates a `file:` URI. + pub fn parse(uri: &str) -> Result { + Url::parse(uri)?.try_into() + } + + /// Converts an absolute path on the current host to a `file:` URI. + /// + /// On Unix, [`AbsolutePathBuf`]'s absolute-path invariant is sufficient for + /// `url` to represent the path. On Windows, conversion can still fail for + /// absolute paths whose prefix has no `file:` URI representation, including + /// `\\.\` device paths and generic `\\?\` verbatim namespaces. Those cases + /// return [`PathUriParseError::InvalidFileUriPath`]. + pub fn from_file_path(path: &AbsolutePathBuf) -> Result { + let url = Url::from_file_path(path.as_path()) + .map_err(|()| PathUriParseError::InvalidFileUriPath)?; + Self::try_from(url) + } + + /// Returns the percent-encoded URI path. + /// + /// The URL authority is not included. For example, + /// `file://server/share/file.rs` has the path `/share/file.rs`. + pub fn encoded_path(&self) -> &str { + self.0.path() + } + + /// Returns the decoded final URI path segment, or `None` for the URI root. + /// + /// If the segment contains non-UTF-8 encoded bytes, its percent-encoded + /// spelling is returned instead. + pub fn basename(&self) -> Option { + self.0 + .path_segments()? + .rfind(|segment| !segment.is_empty()) + .map(decode_uri_path) + } + + /// Returns the parent URI, or `None` for the URI root. + pub fn parent(&self) -> Option { + if self.encoded_path() == "/" { + return None; + } + + let mut url = self.0.clone(); + { + let mut segments = match url.path_segments_mut() { + Ok(segments) => segments, + Err(()) => unreachable!("validated file URLs support hierarchical path segments"), + }; + segments.pop_if_empty().pop(); + } + Some(Self(url)) + } + + /// Lexically joins a relative URI path onto this URI. + /// + /// Empty and `.` segments are ignored, while `..` removes one segment + /// without escaping the URI root. Literal `%`, `?`, and `#` characters are + /// percent-encoded as filename text. Paths containing a null character are + /// rejected because they cannot be safely converted to native paths. + pub fn join(&self, path: &str) -> Result { + if path.starts_with('/') { + return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string())); + } + if path.contains('\0') { + return Err(PathUriParseError::InvalidFileUriPath); + } + if path.is_empty() { + return Ok(self.clone()); + } + + let mut url = self.0.clone(); + { + let Ok(mut segments) = url.path_segments_mut() else { + unreachable!("validated file URLs support hierarchical path segments"); + }; + segments.pop_if_empty(); + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + segments.pop(); + } + component => { + segments.push(component); + } + } + } + } + Self::try_from(url) + } + + /// Converts this file URI to a path using the current host's path rules. + /// + /// Conversion should succeed when the URI was created from an + /// [`AbsolutePathBuf`] on the current host. It may fail when the URI came + /// from a different operating system and its `file:` URI form cannot be + /// represented using the current host's path rules, such as a UNC authority + /// on POSIX or a POSIX root on Windows. Because a `file:` URI does not record + /// its source operating system, callers should only use this method when the + /// URI is known to identify a path on the current host. + pub fn to_native_path(&self) -> Result { + let path = self + .0 + .to_file_path() + .map_err(|()| PathUriParseError::InvalidFileUriPath)?; + AbsolutePathBuf::from_absolute_path_checked(path) + .map_err(|_| PathUriParseError::InvalidFileUriPath) + } + + /// Returns a clone of the canonical URL. + pub fn to_url(&self) -> Url { + self.0.clone() + } +} + +impl TryFrom for PathUri { + type Error = PathUriParseError; + + fn try_from(url: Url) -> Result { + if url.scheme() != FILE_SCHEME { + return Err(PathUriParseError::UnsupportedScheme( + url.scheme().to_string(), + )); + } + validate_file_url(&url)?; + let url = without_localhost_authority(url); + Ok(Self(url)) + } +} + +impl TryFrom for PathUri { + type Error = PathUriParseError; + + fn try_from(uri: String) -> Result { + Self::parse(&uri) + } +} + +impl<'de> Deserialize<'de> for PathUri { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let unsupported_scheme = match Url::parse(&value) { + Ok(url) => match Self::try_from(url) { + Ok(uri) => return Ok(uri), + // `Url` parses a Windows drive prefix such as `C:\` as the + // scheme `c`. Give any unsupported URI one chance to satisfy + // the native absolute-path invariant before reporting it. + Err(error @ PathUriParseError::UnsupportedScheme(_)) => Some(error), + Err(error) => return Err(serde::de::Error::custom(error)), + }, + Err(url::ParseError::RelativeUrlWithoutBase) => None, + Err(error) => { + return Err(serde::de::Error::custom(PathUriParseError::InvalidUri( + error, + ))); + } + }; + + let path = AbsolutePathBuf::from_absolute_path_checked(value).map_err(|path_error| { + serde::de::Error::custom( + unsupported_scheme + .map_or_else(|| path_error.to_string(), |error| error.to_string()), + ) + })?; + Self::from_file_path(&path).map_err(serde::de::Error::custom) + } +} + +impl FromStr for PathUri { + type Err = PathUriParseError; + + fn from_str(uri: &str) -> Result { + Self::parse(uri) + } +} + +impl fmt::Display for PathUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Serialize for PathUri { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} + +impl JsonSchema for PathUri { + fn schema_name() -> String { + "PathUri".to_string() + } + + fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(generator) + } +} + +/// Removes the local `localhost` alias while retaining non-local UNC authority. +fn without_localhost_authority(mut url: Url) -> Url { + if url.host_str() == Some("localhost") { + let Ok(()) = url.set_host(None) else { + unreachable!("validated file URLs can remove a localhost authority"); + }; + } + url +} + +/// Percent-decodes a URI path when it is valid UTF-8. +/// +/// `file:` URLs may contain encoded non-UTF-8 bytes. In that case the encoded +/// spelling remains available for lexical inspection while the original `Url` +/// is retained for lossless native conversion. +fn decode_uri_path(path: &str) -> String { + urlencoding::decode(path) + .map(std::borrow::Cow::into_owned) + .unwrap_or_else(|_| path.to_string()) +} + +/// Rejects URI metadata that has no defined meaning for `file:` URIs. +fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> { + if !url.username().is_empty() || url.password().is_some() { + return Err(PathUriParseError::CredentialsNotAllowed); + } + if url.port().is_some() { + return Err(PathUriParseError::PortNotAllowed); + } + if url.query().is_some() { + return Err(PathUriParseError::QueryNotAllowed); + } + if url.fragment().is_some() { + return Err(PathUriParseError::FragmentNotAllowed); + } + Ok(()) +} + +/// Applies the common URI checks plus `file:` path-byte restrictions. +fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> { + validate_common_known_uri(url)?; + // `Url` accepts `%00`, but native path APIs use null as a terminator and + // `Url::to_file_path` cannot represent a decoded null byte. + if urlencoding::decode_binary(url.path().as_bytes()).contains(&0) { + return Err(PathUriParseError::InvalidFileUriPath); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PathUriParseError { + #[error("invalid URI: {0}")] + InvalidUri(#[from] url::ParseError), + #[error("unsupported path URI scheme `{0}`")] + UnsupportedScheme(String), + #[error("file URI contains an invalid absolute path")] + InvalidFileUriPath, + #[error("credentials are not allowed in path URIs")] + CredentialsNotAllowed, + #[error("ports are not allowed in path URIs")] + PortNotAllowed, + #[error("query parameters are not allowed in path URIs")] + QueryNotAllowed, + #[error("fragments are not allowed in path URIs")] + FragmentNotAllowed, + #[error("path `{0}` must be relative when joining a path URI")] + JoinPathMustBeRelative(String), +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs new file mode 100644 index 0000000000..6a237a8c1e --- /dev/null +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use pretty_assertions::assert_eq; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; +#[cfg(unix)] +use std::path::PathBuf; + +#[test] +fn file_uri_round_trips_an_absolute_path() { + let path = AbsolutePathBuf::current_dir() + .expect("current directory") + .join("a path/file.rs"); + + let uri = PathUri::from_file_path(&path).expect("path should convert to a file URI"); + + let uri_string = uri.to_string(); + assert!(uri_string.starts_with("file:")); + assert!(uri_string.ends_with("/a%20path/file.rs")); + assert_eq!( + PathUri::parse(&uri_string).expect("serialized URI should parse"), + uri + ); + assert_eq!( + uri.to_native_path() + .expect("local file URI should convert to a native path"), + path + ); +} + +#[test] +fn file_uri_parses_a_windows_path_on_any_host() { + let uri = PathUri::parse("file:///C:/Users/Alice%20Smith/src/main.rs") + .expect("Windows file URI should parse on every host"); + + assert_eq!(uri.encoded_path(), "/C:/Users/Alice%20Smith/src/main.rs"); + assert_eq!(uri.basename(), Some("main.rs".to_string())); + assert_eq!( + uri.to_string(), + "file:///C:/Users/Alice%20Smith/src/main.rs" + ); +} + +#[cfg(windows)] +#[test] +fn file_uri_rejects_windows_prefixes_without_a_uri_representation() { + for native_path in [ + r"\\.\COM1", + r"\\?\Volume{00000000-0000-0000-0000-000000000000}\file.rs", + ] { + let path = AbsolutePathBuf::from_absolute_path_checked(native_path) + .expect("Windows namespace path should be absolute"); + + assert_eq!( + PathUri::from_file_path(&path), + Err(PathUriParseError::InvalidFileUriPath), + "converting {native_path}" + ); + } +} + +#[test] +fn file_uri_parses_a_posix_path_on_any_host() { + let uri = PathUri::parse("file:///home/alice/src/main.rs") + .expect("POSIX file URI should parse on every host"); + + assert_eq!(uri.encoded_path(), "/home/alice/src/main.rs"); + assert_eq!(uri.basename(), Some("main.rs".to_string())); + assert_eq!(uri.to_string(), "file:///home/alice/src/main.rs"); +} + +#[test] +fn file_uri_preserves_paths_that_resemble_windows_paths() { + for (input, expected_path) in [("file:///C:/Project", "/C:/Project"), ("file:///C:", "/C:")] { + let uri = PathUri::parse(input).expect("file URI should parse"); + let reparsed = PathUri::parse(&uri.to_string()).expect("file URI should reparse"); + assert_eq!(uri.encoded_path(), expected_path); + assert_eq!(reparsed, uri); + } +} + +#[test] +#[cfg(unix)] +fn file_uri_accepts_non_utf8_posix_paths() { + let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec())); + let path = AbsolutePathBuf::from_absolute_path_checked(path).expect("absolute POSIX path"); + + let uri = PathUri::from_file_path(&path).expect("non-UTF-8 path should convert to a file URI"); + assert_eq!( + uri.to_native_path() + .expect("URI should convert to native path"), + path + ); + assert_eq!( + PathUri::parse(&uri.to_string()).expect("non-UTF-8 URI should reparse"), + uri + ); +} + +#[test] +fn file_uri_round_trips_literal_percent_characters() { + let uri = PathUri::parse("file:///tmp/100%25/file").expect("file URI should parse"); + + assert_eq!(uri.to_string(), "file:///tmp/100%25/file"); + assert_eq!(uri.encoded_path(), "/tmp/100%25/file"); + assert_eq!(uri.basename(), Some("file".to_string())); +} + +#[test] +#[cfg(windows)] +fn file_uri_round_trips_windows_unc_paths() { + let path = AbsolutePathBuf::from_absolute_path_checked(r"\\server\share\src\main.rs") + .expect("absolute UNC path"); + let uri = PathUri::from_file_path(&path).expect("UNC path should convert to a file URI"); + + assert_eq!(uri.encoded_path(), "/share/src/main.rs"); + assert_eq!(uri.to_native_path().expect("UNC URI should convert"), path); +} + +#[test] +fn file_uri_retains_unc_authority() { + let uri = PathUri::parse("file://server/share/src/main.rs").expect("valid file URI"); + + assert_eq!(uri.encoded_path(), "/share/src/main.rs"); + assert_eq!(uri.to_string(), "file://server/share/src/main.rs"); +} + +#[test] +fn file_uri_spelling_aliases_have_one_canonical_form() { + for input in [ + "FILE:///workspace/src", + "file:/workspace/src", + "file://localhost/workspace/src", + "file://LOCALHOST/workspace/src", + ] { + let uri = PathUri::parse(input).expect("file URI alias should parse"); + assert_eq!(uri.to_string(), "file:///workspace/src", "parsing {input}"); + } +} + +#[test] +fn unsupported_schemes_are_rejected_at_construction() { + for (input, expected_scheme) in [ + ("codex-env:///devbox/workspace", "codex-env"), + ("artifact://store/object-1", "artifact"), + ("http://example.com/file", "http"), + ("https://example.com/file", "https"), + ("ssh://host/workspace", "ssh"), + ("vscode-remote://ssh-remote+host/workspace", "vscode-remote"), + ("untitled:Untitled-1", "untitled"), + ] { + let error = PathUri::parse(input).expect_err("unsupported schemes should be rejected"); + + assert!( + matches!( + error, + PathUriParseError::UnsupportedScheme(scheme) if scheme == expected_scheme + ), + "parsing {input}" + ); + } +} + +#[test] +fn path_uri_serializes_as_a_string() { + let uri: PathUri = "file:///workspace/src/lib.rs" + .parse() + .expect("valid file URI"); + + let json = serde_json::to_string(&uri).expect("URI should serialize"); + let deserialized: PathUri = serde_json::from_str(&json).expect("URI should deserialize"); + + assert_eq!(json, r#""file:///workspace/src/lib.rs""#); + assert_eq!(deserialized, uri); +} + +#[test] +fn path_uri_deserializes_legacy_absolute_paths() { + let path = AbsolutePathBuf::current_dir() + .expect("current directory") + .join("workspace/src"); + let json = serde_json::to_string(&path).expect("absolute path should serialize"); + let uri: PathUri = serde_json::from_str(&json).expect("legacy absolute path should parse"); + + assert_eq!( + uri, + PathUri::from_file_path(&path).expect("expected file URI") + ); +} + +#[test] +fn path_uri_rejects_legacy_relative_paths_with_absolute_path_guard() { + let base = AbsolutePathBuf::current_dir().expect("current directory"); + let _guard = AbsolutePathBufGuard::new(base.as_path()); + let error = serde_json::from_str::(r#""src/lib.rs""#) + .expect_err("legacy relative path should be rejected"); + + assert!(error.to_string().contains("path is not absolute")); +} + +#[test] +fn unsupported_scheme_is_rejected_during_deserialization() { + let error = serde_json::from_str::(r#""artifact://store/object-1""#) + .expect_err("unsupported scheme should fail deserialization"); + + assert!( + error + .to_string() + .contains("unsupported path URI scheme `artifact`") + ); +} + +#[test] +fn known_path_uris_reject_queries_and_fragments() { + let query_error = + PathUri::parse("file:///tmp/file.rs?version=1").expect_err("query should be rejected"); + let fragment_error = + PathUri::parse("file:///tmp/file.rs#L1").expect_err("fragment should be rejected"); + + assert!(matches!(query_error, PathUriParseError::QueryNotAllowed)); + assert!(matches!( + fragment_error, + PathUriParseError::FragmentNotAllowed + )); +} + +#[test] +fn path_uris_reject_encoded_null_bytes() { + assert!(PathUri::parse("file:///tmp/%00").is_err()); +} + +#[test] +fn encoded_filename_characters_round_trip_without_becoming_uri_metadata() { + let uri = PathUri::parse("file:///tmp/a%3Fb%23c%25d") + .expect("encoded filename characters should parse"); + + assert_eq!(uri.to_string(), "file:///tmp/a%3Fb%23c%25d"); + assert_eq!(uri.encoded_path(), "/tmp/a%3Fb%23c%25d"); + assert_eq!(uri.basename(), Some("a?b#c%d".to_string())); +} + +#[test] +fn double_encoded_separator_remains_filename_text() { + let uri = PathUri::parse("file:///tmp/a%252Fb") + .expect("double-encoded separator should parse as filename text"); + + assert_eq!(uri.to_string(), "file:///tmp/a%252Fb"); + assert_eq!(uri.encoded_path(), "/tmp/a%252Fb"); + assert_eq!(uri.basename(), Some("a%2Fb".to_string())); +} + +#[test] +fn basename_uses_decoded_uri_segments() { + for (input, expected) in [ + ("file:///", None), + ("file:///workspace/src/lib.rs", Some("lib.rs")), + ("file:///workspace/a%20file.rs", Some("a file.rs")), + ("file:///C:/", Some("C:")), + ("file://server/share", Some("share")), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + assert_eq!( + uri.basename(), + expected.map(str::to_string), + "basename for {input}" + ); + } +} + +#[test] +fn parent_uses_uri_hierarchy_and_preserves_authority() { + for (input, expected) in [ + ( + "file:///workspace/src/lib.rs", + Some("file:///workspace/src"), + ), + ("file:///workspace", Some("file:///")), + ("file:///", None), + ("file:///C:/Users", Some("file:///C:")), + ("file:///C:/", Some("file:///")), + ( + "file://server/share/src/main.rs", + Some("file://server/share/src"), + ), + ("file://server/share", Some("file://server/")), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI")); + assert_eq!(uri.parent(), expected, "parent for {input}"); + } +} + +#[test] +fn join_normalizes_relative_uri_segments() { + for (base, relative, expected) in [ + ( + "file:///workspace/src", + "../tests/test.rs", + "file:///workspace/tests/test.rs", + ), + ("file:///", "../../etc", "file:///etc"), + ("file:///C:/Users", "../Windows", "file:///C:/Windows"), + ( + "file://server/share/src", + "../tests", + "file://server/share/tests", + ), + ( + "file:///workspace", + "a?b#c%d", + "file:///workspace/a%3Fb%23c%25d", + ), + ("file:///workspace/", "", "file:///workspace/"), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(relative), Ok(expected), "joining {relative}"); + } +} + +#[test] +fn join_rejects_absolute_and_null_paths() { + let base = PathUri::parse("file:///workspace").expect("valid base URI"); + + assert!(matches!( + base.join("/src"), + Err(PathUriParseError::JoinPathMustBeRelative(path)) if path == "/src" + )); + assert!(matches!( + base.join("src\0file"), + Err(PathUriParseError::InvalidFileUriPath) + )); +} + +#[test] +fn to_url_returns_the_validated_url() { + let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI"); + + assert_eq!( + uri.to_url(), + Url::parse("file:///workspace/a%20file.rs").expect("valid URL") + ); +} From 4ca2e436e50951baa1ea74246fe09bbbff1fda4a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 8 Jun 2026 17:02:36 -0700 Subject: [PATCH 136/141] fix(tui): linkify complete bare URLs with tildes (#27088) ## Background Bare URLs containing `~` in their path are currently only clickable up to the tilde in the interactive TUI. For example, Codex renders the visible text for: `https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf` but the OSC 8 destination stops at `https://www.cs.tufts.edu/`. This makes Cmd-click open the wrong location even though the terminal recognizes the complete URL outside Codex. Fixes #26774. ## Root Cause The URL scanner already accepts `~`. The truncation happens earlier: with strikethrough parsing enabled, `pulldown-cmark` splits this URL into adjacent decoded `Event::Text` values around the tilde. The Markdown renderer annotated each text event independently, so only the first event still looked like a complete URL with a supported scheme. The renderer now merges adjacent decoded text events before URL annotation. It preserves the combined source range while retaining parser-decoded contents, which avoids regressing entities such as `&`. ## Changes - Add a small iterator that merges adjacent decoded Markdown text events and their source ranges. - Apply it at the Markdown renderer boundary before hyperlink detection. - Add regression coverage for the reported URL in prose, wrapped table output, and entity-decoded URLs. ## How to Test 1. Run Codex with `just c`. 2. Ask the assistant to output this exact bare URL with no Markdown link syntax: `https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf` 3. Hold Cmd and hover or click the URL. 4. Confirm the complete URL, including the suffix after `~`, is one destination. 5. Repeat with the URL inside a Markdown table and confirm wrapped portions retain the same complete destination. Targeted tests: - `just test -p codex-tui url_with_tilde` - `just test -p codex-tui merged_text_events_preserve_entity_decoding` The full `codex-tui` test run was also executed. Its only failures were the two existing Guardian feature-flag tests: - `app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` --- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/markdown_render.rs | 3 +- codex-rs/tui/src/markdown_render_tests.rs | 88 +++++++++++++++++++ codex-rs/tui/src/markdown_text_merge.rs | 50 +++++++++++ ...l_with_tilde_keeps_complete_hyperlink.snap | 15 ++++ 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/markdown_text_merge.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5e6b6cf81c..6b98bee190 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -153,6 +153,7 @@ mod local_chatgpt_auth; mod markdown; mod markdown_render; mod markdown_stream; +mod markdown_text_merge; mod mention_codec; mod model_catalog; mod model_migration; diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 9847c6dc3a..52b603bce0 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -39,6 +39,7 @@ //! body rows, or even 3-char-wide columns cannot fit, body rows render as //! key/value records. +use crate::markdown_text_merge::DecodedTextMerge; use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; @@ -322,7 +323,7 @@ pub(crate) fn render_markdown_lines_with_width_and_cwd( let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); - let parser = Parser::new_ext(input, options).into_offset_iter(); + let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter()); let mut w = Writer::new(input, parser, width, cwd); w.run(); w.text diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 206bae1b70..e0e498e69a 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -8,9 +8,11 @@ use std::path::Path; use crate::markdown_render::COLON_LOCATION_SUFFIX_RE; use crate::markdown_render::HASH_LOCATION_SUFFIX_RE; +use crate::markdown_render::render_markdown_lines_with_width_and_cwd; use crate::markdown_render::render_markdown_text; use crate::markdown_render::render_markdown_text_with_width; use crate::markdown_render::render_markdown_text_with_width_and_cwd; +use insta::assert_debug_snapshot; use insta::assert_snapshot; fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> { @@ -29,6 +31,92 @@ fn plain_lines(text: &Text<'_>) -> Vec { .collect() } +#[test] +fn bare_url_with_tilde_keeps_complete_hyperlink() { + let destination = + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf"; + let lines = render_markdown_lines_with_width_and_cwd( + destination, + /*width*/ Some(80), + /*cwd*/ None, + ); + let rendered = lines + .iter() + .map(|line| { + let text = line + .line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let hyperlinks = line + .hyperlinks + .iter() + .map(|link| (link.columns.clone(), link.destination.as_str())) + .collect::>(); + (text, hyperlinks) + }) + .collect::>(); + + assert_debug_snapshot!(rendered); +} + +#[test] +fn table_url_with_tilde_keeps_complete_hyperlink() { + let destination = + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf"; + let markdown = format!("| URL |\n| --- |\n| {destination} |\n"); + let lines = render_markdown_lines_with_width_and_cwd( + &markdown, + /*width*/ Some(32), + /*cwd*/ None, + ); + let destinations = lines + .iter() + .flat_map(|line| line.hyperlinks.iter()) + .map(|link| link.destination.as_str()) + .collect::>(); + + assert!(!destinations.is_empty()); + assert_eq!(destinations, vec![destination; destinations.len()]); +} + +#[test] +fn merged_text_events_preserve_entity_decoding() { + let source = "https://example.com/a&b~c"; + let destination = "https://example.com/a&b~c"; + let lines = render_markdown_lines_with_width_and_cwd( + source, + /*width*/ Some(80), + /*cwd*/ None, + ); + let rendered = lines + .iter() + .map(|line| { + let text = line + .line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let hyperlinks = line + .hyperlinks + .iter() + .map(|link| (link.columns.clone(), link.destination.as_str())) + .collect::>(); + (text, hyperlinks) + }) + .collect::>(); + + assert_eq!( + rendered, + vec![( + destination.to_string(), + vec![(0..destination.len(), destination)], + )], + ); +} + #[test] fn empty() { assert_eq!(render_markdown_text(""), Text::default()); diff --git a/codex-rs/tui/src/markdown_text_merge.rs b/codex-rs/tui/src/markdown_text_merge.rs new file mode 100644 index 0000000000..bad46fe5df --- /dev/null +++ b/codex-rs/tui/src/markdown_text_merge.rs @@ -0,0 +1,50 @@ +//! Markdown text-event merging that preserves parser-decoded contents and source offsets. + +use std::iter::Peekable; +use std::ops::Range; + +use pulldown_cmark::Event; + +/// Merges adjacent parsed text events without reconstructing them from the Markdown source. +/// +/// Markdown extensions can split visually contiguous text around delimiter characters. Keeping the +/// decoded event contents together lets downstream consumers recognize tokens that span those +/// parser boundaries while the combined source range remains available for offset-aware rendering. +pub(crate) struct DecodedTextMerge { + iter: Peekable, +} + +impl DecodedTextMerge { + pub(crate) fn new(iter: I) -> Self { + Self { + iter: iter.peekable(), + } + } +} + +impl<'a, I> Iterator for DecodedTextMerge +where + I: Iterator, Range)>, +{ + type Item = (Event<'a>, Range); + + fn next(&mut self) -> Option { + let (event, mut range) = self.iter.next()?; + let Event::Text(text) = event else { + return Some((event, range)); + }; + if !matches!(self.iter.peek(), Some((Event::Text(_), _))) { + return Some((Event::Text(text), range)); + } + + let mut merged = text.into_string(); + while matches!(self.iter.peek(), Some((Event::Text(_), _))) { + let Some((Event::Text(text), next_range)) = self.iter.next() else { + break; + }; + merged.push_str(&text); + range.end = next_range.end; + } + Some((Event::Text(merged.into()), range)) + } +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap new file mode 100644 index 0000000000..c10dccd9ca --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +[ + ( + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf", + [ + ( + 0..72, + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf", + ), + ], + ), +] From 8534912df930700788ea69812de65e7222eaea53 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 8 Jun 2026 17:03:52 -0700 Subject: [PATCH 137/141] Show effective sandbox modes in /debug-config (#27068) ## Summary - Render `/debug-config`'s `allowed_sandbox_modes` from the finalized permission constraints instead of the raw requirements list. - Add regression coverage for configured full-access and external sandbox modes being omitted when effective permissions reject them. ## Details `allowed_sandbox_modes` comes from managed requirements, but the final permissions can be further constrained by derived validation rules. For example, `permissions.filesystem.deny_read` requires sandbox enforcement, so modes that disable or externalize Codex's sandbox are not actually usable even if they were present in the raw requirements TOML. The debug renderer now enumerates the configured sandbox-mode labels and keeps only those accepted by `Config.permissions`. That makes `/debug-config` reflect the same effective permission-profile constraint path used by runtime config validation, while preserving the existing source/provenance display. ## Validation - Added a regression test for effective sandbox-mode filtering in `/debug-config`. --- codex-rs/tui/src/debug_config.rs | 154 ++++++++++++++++-- ...ffective_sandbox_modes_with_deny_read.snap | 12 ++ 2 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 620b152b99..ebd2042a10 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1,5 +1,6 @@ use crate::history_cell::PlainHistoryCell; use crate::legacy_core::config::Config; +use crate::legacy_core::config::Permissions; use crate::session_state::SessionNetworkProxyRuntime; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; @@ -15,6 +16,8 @@ use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::WebSearchModeRequirement; use codex_config::format_config_layer_source; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use ratatui::style::Stylize; use ratatui::text::Line; use toml::Value as TomlValue; @@ -23,7 +26,9 @@ pub(crate) fn new_debug_config_output( config: &Config, session_network_proxy: Option<&SessionNetworkProxyRuntime>, ) -> PlainHistoryCell { - let mut lines = render_debug_config_lines(&config.config_layer_stack); + let mut lines = render_debug_config_lines(&config.config_layer_stack, |mode| { + sandbox_mode_is_allowed_by_permissions(&config.permissions, mode) + }); if let Some(proxy) = session_network_proxy { lines.push("".into()); @@ -49,6 +54,24 @@ pub(crate) fn new_debug_config_output( PlainHistoryCell::new(lines) } +fn sandbox_mode_is_allowed_by_permissions( + permissions: &Permissions, + mode: SandboxModeRequirement, +) -> bool { + let permission_profile = match mode { + SandboxModeRequirement::ReadOnly => PermissionProfile::read_only(), + SandboxModeRequirement::WorkspaceWrite => PermissionProfile::workspace_write(), + SandboxModeRequirement::DangerFullAccess => PermissionProfile::Disabled, + SandboxModeRequirement::ExternalSandbox => PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + }; + + permissions + .can_set_permission_profile(&permission_profile) + .is_ok() +} + fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) -> String { if socks_enabled { format!("socks5h://{socks_addr}") @@ -57,7 +80,10 @@ fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) } } -fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { +fn render_debug_config_lines( + stack: &ConfigLayerStack, + sandbox_mode_is_effectively_allowed: impl Fn(SandboxModeRequirement) -> bool, +) -> Vec> { let mut lines = vec!["/debug-config".magenta().into(), "".into()]; lines.push( @@ -122,6 +148,7 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { modes .iter() .copied() + .filter(|mode| sandbox_mode_is_effectively_allowed(*mode)) .map(format_sandbox_mode_requirement) .collect::>(), ); @@ -515,8 +542,11 @@ fn format_network_unix_socket_permission( #[cfg(test)] mod tests { use super::render_debug_config_lines; + use super::sandbox_mode_is_allowed_by_permissions; use super::session_all_proxy_url; use crate::legacy_core::config::Constrained; + use crate::legacy_core::config::ConstraintError; + use crate::legacy_core::config::Permissions; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; @@ -542,6 +572,7 @@ mod tests { use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::WebSearchModeRequirement; + use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; @@ -571,6 +602,20 @@ mod tests { .join("\n") } + fn render_stack_to_text(stack: &ConfigLayerStack) -> String { + render_stack_to_text_with_sandbox_mode_filter(stack, |_| true) + } + + fn render_stack_to_text_with_sandbox_mode_filter( + stack: &ConfigLayerStack, + sandbox_mode_is_effectively_allowed: impl Fn(SandboxModeRequirement) -> bool, + ) -> String { + render_to_text(&render_debug_config_lines( + stack, + sandbox_mode_is_effectively_allowed, + )) + } + #[test] fn debug_config_output_lists_all_layers_including_disabled() { let system_file = if cfg!(windows) { @@ -604,7 +649,7 @@ mod tests { ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("(enabled)")); assert!(rendered.contains("(disabled)")); assert!(rendered.contains("reason: project is untrusted")); @@ -749,7 +794,7 @@ mod tests { ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_approval_policies: on-request (source: {requirements_source})" @@ -800,6 +845,93 @@ mod tests { assert!(!rendered.contains(" - rules:")); } + #[test] + fn debug_config_output_filters_sandbox_modes_blocked_by_deny_read_requirements() { + let requirements_file = if cfg!(windows) { + absolute_path("C:\\ProgramData\\OpenAI\\Codex\\requirements.toml") + } else { + absolute_path("/etc/codex/requirements.toml") + }; + let denied_path = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.gitconfig") + } else { + absolute_path("/home/alice/.gitconfig") + }; + + let requirements = ConfigRequirements { + permission_profile: ConstrainedWithSource::new( + Constrained::allow_any(PermissionProfile::read_only()), + Some(RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }), + ), + filesystem: Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![denied_path.into()], + }, + RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }, + )), + ..ConfigRequirements::default() + }; + let requirements_toml = ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + SandboxModeRequirement::DangerFullAccess, + SandboxModeRequirement::ExternalSandbox, + ]), + ..ConfigRequirementsToml::default() + }; + let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("config layer stack"); + let constrained_permission_profile = + Constrained::new(PermissionProfile::read_only(), |candidate| { + let mode = sandbox_mode_requirement_for_permission_profile(candidate); + match mode { + SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => { + Ok(()) + } + SandboxModeRequirement::DangerFullAccess + | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + } + }) + .expect("constrained permission profile"); + let permissions = Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::OnRequest.to_core()), + constrained_permission_profile, + ) + .expect("permissions"); + + let rendered = render_stack_to_text_with_sandbox_mode_filter(&stack, |mode| { + sandbox_mode_is_allowed_by_permissions(&permissions, mode) + }); + #[cfg(not(windows))] + insta::assert_snapshot!( + "debug_config_effective_sandbox_modes_with_deny_read", + rendered.as_str() + ); + assert!( + rendered.contains( + format!( + "allowed_sandbox_modes: read-only, workspace-write (source: {})", + requirements_file.as_path().display() + ) + .as_str() + ) + ); + assert!(!rendered.contains("danger-full-access")); + assert!(!rendered.contains("external-sandbox")); + } + #[test] fn debug_config_output_lists_approvals_reviewer_as_requirement() { let requirements = ConfigRequirements { @@ -816,7 +948,7 @@ mod tests { let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains( "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); @@ -851,7 +983,7 @@ mod tests { ConfigLayerStack::new(Vec::new(), requirements, ConfigRequirementsToml::default()) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" @@ -880,7 +1012,7 @@ writable_roots = ["/tmp"] ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("session-flags (enabled)")); assert!(rendered.contains(" - model = \"gpt-5\"")); assert!(rendered.contains(" - sandbox_workspace_write.network_access = true")); @@ -914,7 +1046,7 @@ approval_policy = "never" ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("legacy managed_config.toml (MDM) (enabled)")); assert!(rendered.contains("MDM value:")); assert!(rendered.contains("# managed by MDM")); @@ -951,7 +1083,7 @@ approval_policy = "never" ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("enterprise-managed (Base policy, cfg_123) (enabled)")); assert!(rendered.contains("Enterprise-managed config value:")); assert!(!rendered.contains("MDM value:")); @@ -997,7 +1129,7 @@ approval_policy = "never" let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_web_search_modes: disabled (source: {requirements_source})" @@ -1043,7 +1175,7 @@ approval_policy = "never" let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap new file mode 100644 index 0000000000..b4ccc63a9a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/debug_config.rs +expression: rendered.as_str() +--- +/debug-config + +Config layer stack (lowest precedence first): + + +Requirements: + - allowed_sandbox_modes: read-only, workspace-write (source: /etc/codex/requirements.toml) + - permissions.filesystem.deny_read: /home/alice/.gitconfig (source: /etc/codex/requirements.toml) From e127a0cf99195e94e8762c40c6ba17fd6158ac29 Mon Sep 17 00:00:00 2001 From: Boyang Niu Date: Mon, 8 Jun 2026 17:38:35 -0700 Subject: [PATCH 138/141] Add extra config to StoredThread, leave empty for now (#27092) --- codex-rs/app-server/src/bespoke_event_handling.rs | 1 + .../request_processors/external_agent_session_import.rs | 1 + .../src/request_processors/thread_processor_tests.rs | 1 + codex-rs/app-server/tests/suite/conversation_summary.rs | 1 + codex-rs/app-server/tests/suite/v2/thread_read.rs | 1 + codex-rs/app-server/tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/config/mod.rs | 5 +++++ codex-rs/core/src/realtime_context_tests.rs | 1 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 2 ++ codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/thread-store/src/in_memory.rs | 1 + codex-rs/thread-store/src/lib.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + codex-rs/thread-store/src/local/read_thread.rs | 2 ++ codex-rs/thread-store/src/types.rs | 8 ++++++++ 18 files changed, 31 insertions(+) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4549eab68d..d4d6f59349 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2181,6 +2181,7 @@ mod tests { ]; let stored_thread = StoredThread { thread_id, + extra_config: None, rollout_path: None, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index 163210f164..4cec9bca16 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -177,6 +177,7 @@ impl ExternalAgentSessionImporter { let now = Utc::now(); let create_params = CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: source.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 49941dd839..54c0f0343f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -394,6 +394,7 @@ mod thread_processor_behavior_tests { ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); let stored_thread = StoredThread { thread_id, + extra_config: None, rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 05e3ad1c77..6ad9f1c633 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -122,6 +122,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> store .create_thread(CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 0a99d30ada..272ecac79f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1356,6 +1356,7 @@ async fn seed_pathless_store_thread( store .create_thread(CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: ProtocolSessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 84fffd19ba..7267ab073a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -209,6 +209,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { store .create_thread(CreateThreadParams { thread_id, + extra_config: None, forked_from_id: Some(parent_thread_id), parent_thread_id: None, source: SessionSource::Cli, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index d1c7c7a18b..47452eb032 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -35,6 +35,7 @@ pub use codex_core::ThreadManager; pub use codex_core::ThreadShutdownReport; pub use codex_core::config::Config; pub use codex_core::config::Constrained; +pub use codex_core::config::ExtraConfig; pub use codex_core::config::GhostSnapshotConfig; pub use codex_core::config::MultiAgentV2Config; pub use codex_core::config::Permissions; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3f7a7370a0..93e203053f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -103,6 +103,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SandboxPolicy; +pub use codex_thread_store::ExtraConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use rmcp::model::ElicitationCapability; @@ -870,6 +871,9 @@ pub struct Config { /// When true, session is not persisted on disk. Default to `false` pub ephemeral: bool, + /// Optional extra configuration fields for the thread. + pub extra_config: Option, + /// Whether enabled hooks should run without requiring persisted hook trust for this session. /// /// This is a runtime-only knob populated from invocation overrides, not from config files. @@ -3525,6 +3529,7 @@ impl Config { config_layer_stack, history, ephemeral: ephemeral.unwrap_or_default(), + extra_config: None, bypass_hook_trust, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_self_exe, diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 8e74599897..8c3aa8d97e 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -30,6 +30,7 @@ use tempfile::TempDir; fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThread { StoredThread { thread_id: ThreadId::new(), + extra_config: None, rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 37a501fb38..c6d0d9767e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -543,6 +543,7 @@ impl Session { Arc::clone(&thread_store), CreateThreadParams { thread_id, + extra_config: config.extra_config.clone(), forked_from_id, parent_thread_id, source: session_source, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d44c7fbdcc..ef3f6dbfba 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3526,6 +3526,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { Arc::clone(&session.services.thread_store), CreateThreadParams { thread_id: session.thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, @@ -6360,6 +6361,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { Arc::clone(&thread_store), CreateThreadParams { thread_id: session.thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 91fd7a0b3e..5ffb8c4c0d 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -238,6 +238,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R codex_home, history: History::default(), ephemeral: true, + extra_config: None, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: arg0_paths.codex_self_exe, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 3a44c26b32..edc308a905 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -356,6 +356,7 @@ fn stored_thread_from_state( Ok(StoredThread { thread_id, + extra_config: created.extra_config.clone(), rollout_path: metadata .and_then(|metadata| metadata.rollout_path.clone()) .or(rollout_path), diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index 157d13004f..84b72f23f0 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -25,6 +25,7 @@ pub use types::AppendThreadItemsParams; pub use types::ArchiveThreadParams; pub use types::ClearableField; pub use types::CreateThreadParams; +pub use types::ExtraConfig; pub use types::GitInfoPatch; pub use types::ItemPage; pub use types::ListItemsParams; diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index ae0650855a..5acdcf09b6 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -122,6 +122,7 @@ pub(super) fn stored_thread_from_rollout_item( Some(StoredThread { thread_id, + extra_config: None, rollout_path: Some(rollout_path), forked_from_id: None, parent_thread_id: item.parent_thread_id, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 146bfbd843..cef4496a02 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1013,6 +1013,7 @@ mod tests { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { thread_id, + extra_config: None, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 52c8eb7a38..24bc32993c 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -325,6 +325,7 @@ async fn stored_thread_from_sqlite_metadata( permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path()); StoredThread { thread_id: metadata.id, + extra_config: None, rollout_path: Some(rollout_path), forked_from_id, parent_thread_id, @@ -390,6 +391,7 @@ fn stored_thread_from_meta_line( let rollout_path = codex_rollout::plain_rollout_path(path.as_path()); StoredThread { thread_id: meta_line.meta.id, + extra_config: None, rollout_path: Some(rollout_path), forked_from_id: meta_line.meta.forked_from_id, parent_thread_id: meta_line.meta.parent_thread_id, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 1ab42a3117..1f18adf081 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -56,11 +56,17 @@ pub struct ThreadPersistenceMetadata { pub memory_mode: MemoryMode, } +/// Extra configuration fields for a thread. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtraConfig {} + /// Parameters required to create a persisted thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateThreadParams { /// Thread id generated by Codex before opening persistence. pub thread_id: ThreadId, + /// Optional extra configuration fields for the thread. + pub extra_config: Option, /// Source thread id when this thread is created as a fork. pub forked_from_id: Option, /// The ID of the parent thread. This will only be set if this thread is a subagent. @@ -349,6 +355,8 @@ pub struct ItemPage { pub struct StoredThread { /// Thread id. pub thread_id: ThreadId, + /// Optional extra configuration fields for the thread. + pub extra_config: Option, /// Local rollout path when the backing store is filesystem-based. pub rollout_path: Option, /// Source thread id when this thread was forked from another thread. From c656cc4a83eaeb1289c8c8c66ea72b35dbc6e28b Mon Sep 17 00:00:00 2001 From: yuning-oai Date: Mon, 8 Jun 2026 17:55:00 -0700 Subject: [PATCH 139/141] Update web search citation prompt (#27096) ## Summary - Update the web search tool prompt to require Markdown links for cited sources. - Explicitly tell the model not to use `turnX`-style citations in responses. ## Context https://openai.slack.com/archives/C0AU83S0ZQU/p1780964147777649?thread_ts=1780352049.512299&cid=C0AU83S0ZQU ## Test plan - `git diff --check` - `python3 scripts/format.py --check` (fails only on Rust formatter setup: rustup cannot create temp files under `/home/dev-user/.rustup`; Just and Python formatter checks pass when using temp cache dirs) --- codex-rs/ext/web-search/web_run_description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/ext/web-search/web_run_description.md b/codex-rs/ext/web-search/web_run_description.md index bccc3d81f6..0f22f6df3c 100644 --- a/codex-rs/ext/web-search/web_run_description.md +++ b/codex-rs/ext/web-search/web_run_description.md @@ -77,4 +77,4 @@ Responses may not excessively quote or draw on a specific source. There are seve --- -Make sure to provide links to the sources you used in your response. +Cite your sources by using markdown links to the sites you used in your response. Do NOT use citations in the `turnX` style. From 0beb5c7f32cf5459a51e3f6bc01e6509d7951854 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 8 Jun 2026 18:08:48 -0700 Subject: [PATCH 140/141] Preserve cloud requirements across TUI thread resets (#25177) Fixes a TUI regression where thread transitions such as `/new` and `/clear` could rebuild config without the cloud requirements loader, allowing users to fall back to non-cloud-managed settings. The config refresh path now preserves cloud requirements during thread reinitialization, and config loading is moved off the deep TUI event stack to avoid stack-overflow crashes during those reloads. - Passes the cloud requirements loader through TUI config rebuild paths. - Keeps cloud requirements applied for `/new`, `/clear`, `/fork`, side conversations, and session picker transitions. - Runs config building on a Tokio task so reloads do not occur on the deep TUI caller stack. - Adds regression coverage that cloud requirements survive thread-transition config refreshes. ## Test/Repro: - Start Codex with a cloud requirement applied. - Use `/new` or `/clear`. - The refreshed/fresh-session config should still include the cloud requirements This can be tested with any config item, at this moment for oai staff the easiest item to test is the `mentions_v2` feature. This is currently enabled in cloud requirements, but is not enabled by default. As a result, prior to these changes that feature is disabled after `/new` or `/clear`. Testing the same steps with a binary from this branch should not drop the feature enablement. --- codex-rs/tui/src/app.rs | 5 ++ codex-rs/tui/src/app/config_persistence.rs | 66 ++++++++++++++++++- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + ...external_agent_config_migration_startup.rs | 3 + codex-rs/tui/src/lib.rs | 1 + 6 files changed, 76 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4b86eda898..de43b32755 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -130,6 +130,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WriteStatus; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::types::ApprovalsReviewer; @@ -490,6 +491,7 @@ pub(crate) struct App { cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, runtime_approval_policy_override: Option, runtime_permission_profile_override: Option, @@ -720,6 +722,7 @@ impl App { cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, @@ -754,6 +757,7 @@ impl App { &mut config, &cli_kv_overrides, &harness_overrides, + &cloud_config_bundle, entered_trust_nux, ) .await?; @@ -1003,6 +1007,7 @@ See the Codex keymap documentation for supported actions and examples." cli_kv_overrides, harness_overrides, loader_overrides, + cloud_config_bundle, runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 319830b82a..7d1f6047e0 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -34,7 +34,8 @@ impl App { .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()); + .loader_overrides(self.loader_overrides.clone()) + .cloud_config_bundle(self.cloud_config_bundle.clone()); build_config_on_runtime_worker( builder, format!("Failed to rebuild config for cwd {cwd_display}"), @@ -55,7 +56,8 @@ impl App { .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()); + .loader_overrides(self.loader_overrides.clone()) + .cloud_config_bundle(self.cloud_config_bundle.clone()); build_config_on_runtime_worker( builder, format!("Failed to rebuild config for permission profile {profile_id}"), @@ -1123,6 +1125,66 @@ mod tests { Ok(()) } + // Regression coverage for `/new` and `/clear`: cloud requirements + // must survive the config refresh that runs before thread transitions. + #[tokio::test] + async fn refresh_in_memory_config_from_disk_keeps_cloud_requirements_for_thread_transitions() + -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + let required_policy = codex_protocol::protocol::AskForApproval::Never; + let cloud_config_bundle = + codex_config::test_support::CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cloud_config_bundle(cloud_config_bundle.clone()) + .build() + .await?; + app.config = config; + app.cloud_config_bundle = cloud_config_bundle; + let app_id = "unit_test_cloud_requirements_reload_marker"; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +[apps.{app_id}] +enabled = false +"# + ), + )?; + + let assert_cloud_requirements = |app: &App| { + let config = app.fresh_session_config(); + assert_eq!( + config + .config_layer_stack + .requirements_toml() + .allowed_approval_policies + .clone(), + Some(vec![required_policy]) + ); + assert_eq!(config.permissions.approval_policy.value(), required_policy); + }; + + assert_cloud_requirements(&app); + assert_eq!(app_enabled_in_effective_config(&app.config, app_id), None); + + // This is the fallible reload that the best-effort `/new`, `/clear`, + // `/fork`, side-conversation, and session-picker paths wrap. + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app_enabled_in_effective_config(&app.config, app_id), + Some(false) + ); + assert_cloud_requirements(&app); + Ok(()) + } + #[tokio::test] async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index c59102fa6d..4e5aa8b10c 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -25,6 +25,7 @@ pub(super) async fn make_test_app() -> App { cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0be0a1462f..451bfadc1a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3945,6 +3945,7 @@ async fn make_test_app() -> App { cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, @@ -4009,6 +4010,7 @@ async fn make_test_app_with_channels() -> ( cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs index ce184303c2..b2afbe7718 100644 --- a/codex-rs/tui/src/external_agent_config_migration_startup.rs +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -9,6 +9,7 @@ use crate::legacy_core::config::edit::ConfigEditsBuilder; use crate::tui; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigMigrationItem; +use codex_config::CloudConfigBundleLoader; use codex_features::Feature; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -248,6 +249,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed( config: &mut Config, cli_kv_overrides: &[(String, TomlValue)], harness_overrides: &ConfigOverrides, + cloud_config_bundle: &CloudConfigBundleLoader, entered_trust_nux: bool, ) -> Result { if !should_show_external_agent_config_migration_prompt(config, entered_trust_nux) { @@ -321,6 +323,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed( .codex_home(config.codex_home.to_path_buf()) .cli_overrides(cli_kv_overrides.to_vec()) .harness_overrides(harness_overrides.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() .await .wrap_err("Failed to reload config after external agent migration")?; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6b98bee190..f465436404 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1849,6 +1849,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), + cloud_config_bundle, prompt, images, session_selection, From 08cb633c06a27d25872d0132fbd9c749556d7653 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 8 Jun 2026 19:23:35 -0700 Subject: [PATCH 141/141] [codex] Remove remote compaction failure log (#27106) ## Why `log_remote_compact_failure` was the only consumer of the compact-request logging payload and most of the token-usage breakdown fields. Once that failure log is removed, keeping the surrounding carrier types leaves dead plumbing in the compaction path and context manager. ## What changed - Remove `log_remote_compact_failure`, `CompactRequestLogData`, and the v2 wrapper that only fed that log. - Let both remote compaction implementations return the original compaction error directly. - Replace `TotalTokenUsageBreakdown` with a narrow helper that returns only the remaining value needed by compaction analytics. - Keep `estimate_response_item_model_visible_bytes` private to the context manager implementation. ## Validation - `cargo check -p codex-core` --- codex-rs/core/src/compact_remote.rs | 62 +------------------- codex-rs/core/src/compact_remote_v2.rs | 37 ++---------- codex-rs/core/src/context_manager/history.rs | 41 ++----------- codex-rs/core/src/context_manager/mod.rs | 2 - codex-rs/core/src/session/mod.rs | 7 ++- 5 files changed, 17 insertions(+), 132 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 5fca191b84..bd852b4e79 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -8,8 +8,6 @@ use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; -use crate::context_manager::TotalTokenUsageBreakdown; -use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; @@ -35,9 +33,7 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; -use futures::TryFutureExt; use tokio_util::sync::CancellationToken; -use tracing::error; use tracing::info; const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = @@ -199,9 +195,8 @@ async fn run_remote_compact_task_inner_impl( } if estimated_deleted_tokens > 0 { let max_local_deleted_tokens = sess - .get_total_token_usage_breakdown() - .await - .estimated_tokens_of_items_added_since_last_successful_api_response; + .estimated_tokens_after_last_model_generated_item() + .await; analytics_details.active_context_tokens_before = analytics_details .active_context_tokens_before .map(|active_context_tokens_before| { @@ -252,18 +247,6 @@ async fn run_remote_compact_task_inner_impl( &compaction_trace, turn_metadata_header.as_deref(), ) - .or_else(|err| async { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - &err, - ); - Err(err) - }) .await?; new_history = process_compacted_history( sess.as_ref(), @@ -363,47 +346,6 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } } -#[derive(Debug)] -pub(crate) struct CompactRequestLogData { - failing_compaction_request_model_visible_bytes: i64, -} - -pub(crate) fn build_compact_request_log_data( - input: &[ResponseItem], - instructions: &str, -) -> CompactRequestLogData { - let failing_compaction_request_model_visible_bytes = input - .iter() - .map(estimate_response_item_model_visible_bytes) - .fold( - i64::try_from(instructions.len()).unwrap_or(i64::MAX), - i64::saturating_add, - ); - - CompactRequestLogData { - failing_compaction_request_model_visible_bytes, - } -} - -pub(crate) fn log_remote_compact_failure( - turn_context: &TurnContext, - log_data: &CompactRequestLogData, - total_usage_breakdown: TotalTokenUsageBreakdown, - err: &CodexErr, -) { - error!( - turn_id = %turn_context.sub_id, - last_api_response_total_tokens = total_usage_breakdown.last_api_response_total_tokens, - all_history_items_model_visible_bytes = total_usage_breakdown.all_history_items_model_visible_bytes, - estimated_tokens_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_tokens_of_items_added_since_last_successful_api_response, - estimated_bytes_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_bytes_of_items_added_since_last_successful_api_response, - model_context_window_tokens = ?turn_context.model_context_window(), - failing_compaction_request_model_visible_bytes = log_data.failing_compaction_request_model_visible_bytes, - compact_error = %err, - "remote compaction failed" - ); -} - pub(crate) fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 1d4eb8ad5a..a80d4dd56f 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -8,8 +8,6 @@ use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; -use crate::compact_remote::build_compact_request_log_data; -use crate::compact_remote::log_remote_compact_failure; use crate::compact_remote::process_compacted_history; use crate::compact_remote::should_keep_compacted_history_item; use crate::compact_remote::trim_function_call_history_to_fit_context_window; @@ -212,9 +210,8 @@ async fn run_remote_compact_task_inner_impl( } if estimated_deleted_tokens > 0 { let max_local_deleted_tokens = sess - .get_total_token_usage_breakdown() - .await - .estimated_tokens_of_items_added_since_last_successful_api_response; + .estimated_tokens_after_last_model_generated_item() + .await; analytics_details.active_context_tokens_before = analytics_details .active_context_tokens_before .map(|active_context_tokens_before| { @@ -354,12 +351,9 @@ async fn run_remote_compaction_request_v2( match result { Ok(compaction_output) => return Ok(compaction_output), - Err(err) if !err.is_retryable() => { - log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; - return Err(err); - } + Err(err) if !err.is_retryable() => return Err(err), Err(err) => { - if let Err(err) = handle_retryable_response_stream_error( + handle_retryable_response_stream_error( &mut retries, max_retries, err, @@ -368,33 +362,12 @@ async fn run_remote_compaction_request_v2( turn_context, ResponsesStreamRequest::RemoteCompactionV2, ) - .await - { - log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; - return Err(err); - } + .await?; } } } } -async fn log_remote_compaction_request_failure( - sess: &Session, - turn_context: &TurnContext, - prompt: &Prompt, - err: &CodexErr, -) { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - err, - ); -} - async fn collect_compaction_output( mut stream: ResponseStream, ) -> CodexResult { diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b8bcf9c846..479ccc89c6 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -50,14 +50,6 @@ pub(crate) struct ContextManager { reference_context_item: Option, } -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct TotalTokenUsageBreakdown { - pub last_api_response_total_tokens: i64, - pub all_history_items_model_visible_bytes: i64, - pub estimated_tokens_of_items_added_since_last_successful_api_response: i64, - pub estimated_bytes_of_items_added_since_last_successful_api_response: i64, -} - impl ContextManager { pub(crate) fn new() -> Self { Self { @@ -321,32 +313,11 @@ impl ContextManager { } } - pub(crate) fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown { - let last_usage = self - .token_info - .as_ref() - .map(|info| info.last_token_usage.clone()) - .unwrap_or_default(); - let items_after_last_model_generated = self.items_after_last_model_generated_item(); - - TotalTokenUsageBreakdown { - last_api_response_total_tokens: last_usage.total_tokens, - all_history_items_model_visible_bytes: self - .items - .iter() - .map(estimate_response_item_model_visible_bytes) - .fold(0i64, i64::saturating_add), - estimated_tokens_of_items_added_since_last_successful_api_response: - items_after_last_model_generated - .iter() - .map(estimate_item_token_count) - .fold(0i64, i64::saturating_add), - estimated_bytes_of_items_added_since_last_successful_api_response: - items_after_last_model_generated - .iter() - .map(estimate_response_item_model_visible_bytes) - .fold(0i64, i64::saturating_add), - } + pub(crate) fn estimated_tokens_after_last_model_generated_item(&self) -> i64 { + self.items_after_last_model_generated_item() + .iter() + .map(estimate_item_token_count) + .fold(0i64, i64::saturating_add) } /// This function enforces a couple of invariants on the in-memory history: @@ -532,7 +503,7 @@ static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock i64 { +fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 { match item { ResponseItem::Reasoning { encrypted_content: Some(content), diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 2295c49df3..f811c8b373 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -3,7 +3,5 @@ mod normalize; pub(crate) mod updates; pub(crate) use history::ContextManager; -pub(crate) use history::TotalTokenUsageBreakdown; -pub(crate) use history::estimate_response_item_model_visible_bytes; pub(crate) use history::is_user_turn_boundary; pub(crate) use history::truncate_function_output_payload; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f3e506d40f..bb66e8684e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -182,7 +182,6 @@ use crate::config::PermissionProfileState; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; -use crate::context_manager::TotalTokenUsageBreakdown; use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerSource; @@ -1145,9 +1144,11 @@ impl Session { state.auto_compact_window_snapshot() } - pub(crate) async fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown { + pub(crate) async fn estimated_tokens_after_last_model_generated_item(&self) -> i64 { let state = self.state.lock().await; - state.history.get_total_token_usage_breakdown() + state + .history + .estimated_tokens_after_last_model_generated_item() } pub(crate) async fn total_token_usage(&self) -> Option {