From f705f42ba8e87c455b028623c27d7f0b173b2f10 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 17 Apr 2026 15:39:07 -0700 Subject: [PATCH 01/18] fix: fix fs sandbox helper for apply_patch (#18296) ## Summary - pass split filesystem sandbox policy/cwd through apply_patch contexts, while omitting legacy-equivalent policies to keep payloads small - keep the fs helper compatible with legacy Landlock by avoiding helper read-root permission expansion in that mode and disabling helper network access ## Root Cause `d626dc38950fb40a1a5ad0a8ffab2485e3348c53` routed exec-server filesystem operations through a sandboxed helper. That path forwarded legacy Landlock into a helper policy shape that could require direct split-policy enforcement. Sandboxed `apply_patch` hit that edge through the filesystem abstraction. The same 0.121 edit-regression path is consistent with #18354: normal writes route through the `apply_patch` filesystem helper, fail under sandbox, and then surface the generic retry-without-sandbox prompt. Fixes #18069 Fixes #18354 ## Validation - `cd codex-rs && just fmt` - earlier branch validation before merging current `origin/main` and dropping the now-separate PATH fix: - `cd codex-rs && cargo test -p codex-exec-server` - `cd codex-rs && cargo test -p codex-core file_system_sandbox_context` - `cd codex-rs && just fix -p codex-exec-server` - `cd codex-rs && just fix -p codex-core` - `git diff --check` - `cd codex-rs && cargo clean` --------- Co-authored-by: Codex --- codex-rs/core/src/session/turn_context.rs | 29 +-- .../core/src/tools/runtimes/apply_patch.rs | 11 + .../src/tools/runtimes/apply_patch_tests.rs | 56 ++++- codex-rs/exec-server/src/file_system.rs | 7 + codex-rs/exec-server/src/fs_sandbox.rs | 221 ++++++++++++++---- 5 files changed, 259 insertions(+), 65 deletions(-) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index cec4ebcded..f647c64956 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -209,6 +209,8 @@ impl TurnContext { ) -> FileSystemSandboxContext { FileSystemSandboxContext { sandbox_policy: self.sandbox_policy.get().clone(), + sandbox_policy_cwd: Some(self.cwd.clone()), + file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -219,6 +221,19 @@ impl TurnContext { } } + fn non_legacy_file_system_sandbox_policy(&self) -> Option { + // Omit the derived split filesystem policy when it is equivalent to + // the legacy sandbox policy. This keeps turn-context payloads stable + // while both fields exist; once callers consume only the split policy, + // this comparison and the legacy projection should go away. + let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + self.sandbox_policy.get(), + &self.cwd, + ); + (self.file_system_sandbox_policy != legacy_file_system_sandbox_policy) + .then(|| self.file_system_sandbox_policy.clone()) + } + pub(crate) fn compact_prompt(&self) -> &str { self.compact_prompt .as_deref() @@ -226,18 +241,6 @@ impl TurnContext { } pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { - let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( - self.sandbox_policy.get(), - &self.cwd, - ); - // Omit the derived split filesystem policy when it is equivalent to - // the legacy sandbox policy. This keeps turn-context payloads stable - // while both fields exist; once callers consume only the split policy, - // this comparison and the legacy projection should go away. - let file_system_sandbox_policy = (self.file_system_sandbox_policy - != legacy_file_system_sandbox_policy) - .then(|| self.file_system_sandbox_policy.clone()); - TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), @@ -247,7 +250,7 @@ impl TurnContext { approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), - file_system_sandbox_policy, + file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 1d5c8a1e36..aac8e6bc13 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -22,6 +22,7 @@ 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::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -74,8 +75,18 @@ impl ApplyPatchRuntime { return None; } + let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + attempt.policy, + attempt.sandbox_cwd, + ); + let file_system_sandbox_policy = (attempt.file_system_policy + != &legacy_file_system_sandbox_policy) + .then(|| attempt.file_system_policy.clone()); + Some(FileSystemSandboxContext { sandbox_policy: attempt.policy.clone(), + sandbox_policy_cwd: Some(attempt.sandbox_cwd.clone()), + file_system_sandbox_policy, windows_sandbox_level: attempt.windows_sandbox_level, windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, use_legacy_landlock: attempt.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 0ba3e131af..0511ca91b6 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -3,6 +3,9 @@ use crate::tools::sandboxing::SandboxAttempt; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; 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::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; @@ -99,7 +102,12 @@ fn file_system_sandbox_context_uses_active_attempt() { permissions_preapproved: false, }; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let mut file_system_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, path.as_path()); + file_system_policy.entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::None, + }); let manager = SandboxManager::new(); let attempt = SandboxAttempt { sandbox: SandboxType::MacosSeatbelt, @@ -119,6 +127,11 @@ fn file_system_sandbox_context_uses_active_attempt() { .expect("sandbox context"); assert_eq!(sandbox.sandbox_policy, sandbox_policy); + assert_eq!(sandbox.sandbox_policy_cwd, Some(path.clone())); + assert_eq!( + sandbox.file_system_sandbox_policy, + Some(file_system_policy.clone()) + ); assert_eq!(sandbox.additional_permissions, Some(additional_permissions)); assert_eq!( sandbox.windows_sandbox_level, @@ -128,6 +141,47 @@ fn file_system_sandbox_context_uses_active_attempt() { assert_eq!(sandbox.use_legacy_landlock, true); } +#[test] +fn file_system_sandbox_context_omits_legacy_equivalent_policy() { + let path = std::env::temp_dir() + .join("apply-patch-runtime-legacy-equivalent.txt") + .abs(); + let req = ApplyPatchRequest { + action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), + file_paths: vec![path.clone()], + changes: HashMap::new(), + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + additional_permissions: None, + permissions_preapproved: false, + }; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let file_system_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, path.as_path()); + let manager = SandboxManager::new(); + let attempt = SandboxAttempt { + sandbox: SandboxType::MacosSeatbelt, + policy: &sandbox_policy, + file_system_policy: &file_system_policy, + network_policy: NetworkSandboxPolicy::Restricted, + enforce_managed_network: false, + manager: &manager, + sandbox_cwd: &path, + codex_linux_sandbox_exe: None, + use_legacy_landlock: true, + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, + windows_sandbox_private_desktop: true, + }; + + let sandbox = ApplyPatchRuntime::file_system_sandbox_context_for_attempt(&req, &attempt) + .expect("sandbox context"); + + assert_eq!(sandbox.sandbox_policy_cwd, Some(path)); + assert_eq!(sandbox.file_system_sandbox_policy, None); +} + #[test] fn no_sandbox_attempt_has_no_file_system_context() { let path = std::env::temp_dir() diff --git a/codex-rs/exec-server/src/file_system.rs b/codex-rs/exec-server/src/file_system.rs index b09347686a..64ebc891db 100644 --- a/codex-rs/exec-server/src/file_system.rs +++ b/codex-rs/exec-server/src/file_system.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use tokio::io; @@ -41,6 +42,10 @@ pub struct ReadDirectoryEntry { #[serde(rename_all = "camelCase")] pub struct FileSystemSandboxContext { pub sandbox_policy: SandboxPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_policy_cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_system_sandbox_policy: Option, pub windows_sandbox_level: WindowsSandboxLevel, #[serde(default)] pub windows_sandbox_private_desktop: bool, @@ -53,6 +58,8 @@ impl FileSystemSandboxContext { pub fn new(sandbox_policy: SandboxPolicy) -> Self { Self { sandbox_policy, + sandbox_policy_cwd: None, + file_system_sandbox_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 04cded30eb..059b33c04d 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -12,6 +12,7 @@ use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_preserving_symlinks; use tokio::io::AsyncWriteExt; @@ -35,6 +36,13 @@ pub(crate) struct FileSystemSandboxRunner { helper_env: HashMap, } +struct HelperSandboxInputs { + sandbox_policy: SandboxPolicy, + file_system_policy: FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + cwd: AbsolutePathBuf, +} + impl FileSystemSandboxRunner { pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self { Self { @@ -48,19 +56,14 @@ impl FileSystemSandboxRunner { sandbox: &FileSystemSandboxContext, request: FsHelperRequest, ) -> Result { - let helper_sandbox_policy = normalize_sandbox_policy_root_aliases( - sandbox_policy_with_helper_runtime_defaults(&sandbox.sandbox_policy), - ); - let cwd = current_sandbox_cwd().map_err(io_error)?; - let cwd = AbsolutePathBuf::from_absolute_path(cwd.as_path()) - .map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?; - let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( - &helper_sandbox_policy, - cwd.as_path(), - ); - let network_policy = NetworkSandboxPolicy::Restricted; + let HelperSandboxInputs { + sandbox_policy, + file_system_policy, + network_policy, + cwd, + } = helper_sandbox_inputs(sandbox)?; let command = self.sandbox_exec_request( - &helper_sandbox_policy, + &sandbox_policy, &file_system_policy, network_policy, &cwd, @@ -92,8 +95,9 @@ impl FileSystemSandboxRunner { args: vec![CODEX_FS_HELPER_ARG1.to_string()], cwd: cwd.clone(), env: self.helper_env.clone(), - additional_permissions: Some( - self.helper_permissions(sandbox_context.additional_permissions.as_ref()), + additional_permissions: self.helper_permissions( + sandbox_context.additional_permissions.as_ref(), + /*include_helper_read_root*/ !sandbox_context.use_legacy_landlock, ), }; sandbox_manager @@ -117,36 +121,68 @@ impl FileSystemSandboxRunner { fn helper_permissions( &self, additional_permissions: Option<&PermissionProfile>, - ) -> PermissionProfile { - let helper_read_root = self - .runtime_paths - .codex_self_exe - .parent() - .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()); - let file_system = - match additional_permissions.and_then(|permissions| permissions.file_system.clone()) { - Some(mut file_system) => { - if let Some(helper_read_root) = &helper_read_root { - let read_paths = file_system.read.get_or_insert_with(Vec::new); - if !read_paths.contains(helper_read_root) { - read_paths.push(helper_read_root.clone()); - } - } - Some(file_system) - } - None => helper_read_root.map(|helper_read_root| FileSystemPermissions { + include_helper_read_root: bool, + ) -> Option { + let inherited_permissions = additional_permissions + .map(|permissions| PermissionProfile { + network: None, + file_system: permissions.file_system.clone(), + }) + .filter(|permissions| !permissions.is_empty()); + let helper_permissions = include_helper_read_root + .then(|| { + self.runtime_paths + .codex_self_exe + .parent() + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + }) + .flatten() + .map(|helper_read_root| PermissionProfile { + network: None, + file_system: Some(FileSystemPermissions { read: Some(vec![helper_read_root]), write: None, }), - }; + }); - PermissionProfile { - network: None, - file_system, - } + merge_permission_profiles(inherited_permissions.as_ref(), helper_permissions.as_ref()) } } +fn helper_sandbox_inputs( + sandbox: &FileSystemSandboxContext, +) -> Result { + let sandbox_policy = normalize_sandbox_policy_root_aliases( + sandbox_policy_with_helper_runtime_defaults(&sandbox.sandbox_policy), + ); + let cwd = match &sandbox.sandbox_policy_cwd { + Some(cwd) => cwd.clone(), + None if sandbox.file_system_sandbox_policy.is_some() => { + return Err(invalid_request( + "fileSystemSandboxPolicy requires sandboxPolicyCwd".to_string(), + )); + } + None => { + let cwd = current_sandbox_cwd().map_err(io_error)?; + AbsolutePathBuf::from_absolute_path(cwd.as_path()).map_err(|err| { + invalid_request(format!("current directory is not absolute: {err}")) + })? + } + }; + let file_system_policy = sandbox + .file_system_sandbox_policy + .clone() + .unwrap_or_else(|| { + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()) + }); + Ok(HelperSandboxInputs { + sandbox_policy, + file_system_policy, + network_policy: NetworkSandboxPolicy::Restricted, + cwd, + }) +} + fn normalize_sandbox_policy_root_aliases(sandbox_policy: SandboxPolicy) -> SandboxPolicy { let mut sandbox_policy = sandbox_policy; match &mut sandbox_policy { @@ -281,10 +317,21 @@ fn spawn_command( fn sandbox_policy_with_helper_runtime_defaults(sandbox_policy: &SandboxPolicy) -> SandboxPolicy { let mut sandbox_policy = sandbox_policy.clone(); match &mut sandbox_policy { - SandboxPolicy::ReadOnly { access, .. } => enable_platform_defaults(access), + SandboxPolicy::ReadOnly { + access, + network_access, + } => { + enable_platform_defaults(access); + *network_access = false; + } SandboxPolicy::WorkspaceWrite { - read_only_access, .. - } => enable_platform_defaults(read_only_access), + read_only_access, + network_access, + .. + } => { + enable_platform_defaults(read_only_access); + *network_access = false; + } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {} } sandbox_policy @@ -326,11 +373,13 @@ mod tests { use pretty_assertions::assert_eq; use crate::ExecServerRuntimePaths; + use crate::FileSystemSandboxContext; use super::FileSystemSandboxRunner; use super::helper_env; use super::helper_env_from_vars; use super::helper_env_key_is_allowed; + use super::helper_sandbox_inputs; use super::sandbox_policy_with_helper_runtime_defaults; #[test] @@ -365,7 +414,7 @@ mod tests { include_platform_defaults: false, readable_roots: Vec::new(), }, - network_access: false, + network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; @@ -387,6 +436,52 @@ mod tests { ); } + #[test] + fn helper_sandbox_inputs_use_context_cwd_and_file_system_policy() { + let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) + .expect("absolute temp dir"); + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let file_system_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + cwd.as_path(), + ); + let mut sandbox_context = FileSystemSandboxContext::new(sandbox_policy.clone()); + sandbox_context.sandbox_policy_cwd = Some(cwd.clone()); + sandbox_context.file_system_sandbox_policy = Some(file_system_policy.clone()); + + let inputs = helper_sandbox_inputs(&sandbox_context).expect("helper sandbox inputs"); + + assert_eq!(inputs.cwd, cwd); + assert_eq!(inputs.sandbox_policy, sandbox_policy); + assert_eq!(inputs.file_system_policy, file_system_policy); + assert_eq!(inputs.network_policy, NetworkSandboxPolicy::Restricted); + } + + #[test] + fn helper_sandbox_inputs_rejects_file_system_policy_without_cwd() { + let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) + .expect("absolute temp dir"); + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let file_system_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + cwd.as_path(), + ); + let mut sandbox_context = FileSystemSandboxContext::new(sandbox_policy); + sandbox_context.file_system_sandbox_policy = Some(file_system_policy); + + let err = match helper_sandbox_inputs(&sandbox_context) { + Ok(_) => panic!("expected invalid sandbox inputs"), + Err(err) => err, + }; + + assert_eq!( + err.message, + "fileSystemSandboxPolicy requires sandboxPolicyCwd" + ); + } + #[test] fn helper_permissions_strip_network_grants() { let codex_self_exe = std::env::current_exe().expect("current exe"); @@ -403,15 +498,20 @@ mod tests { let writable = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute writable path"); - let permissions = runner.helper_permissions(Some(&PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![writable.clone()]), - }), - })); + let permissions = runner + .helper_permissions( + Some(&PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![writable.clone()]), + }), + }), + /*include_helper_read_root*/ true, + ) + .expect("helper permissions"); assert_eq!(permissions.network, None); assert_eq!( @@ -537,7 +637,11 @@ mod tests { ) .expect("absolute readable path"); - let permissions = runner.helper_permissions(/*additional_permissions*/ None); + let permissions = runner + .helper_permissions( + /*additional_permissions*/ None, /*include_helper_read_root*/ true, + ) + .expect("helper permissions"); assert_eq!(permissions.network, None); assert_eq!( @@ -548,4 +652,19 @@ mod tests { }) ); } + + #[test] + fn legacy_landlock_helper_permissions_do_not_add_helper_read_root() { + let codex_self_exe = std::env::current_exe().expect("current exe"); + let runtime_paths = + ExecServerRuntimePaths::new(codex_self_exe, /*codex_linux_sandbox_exe*/ None) + .expect("runtime paths"); + let runner = FileSystemSandboxRunner::new(runtime_paths); + + let permissions = runner.helper_permissions( + /*additional_permissions*/ None, /*include_helper_read_root*/ false, + ); + + assert_eq!(permissions, None); + } } From 680c4102ae32c27989683fcb30f80e3104a6bccb Mon Sep 17 00:00:00 2001 From: zbarsky-openai Date: Fri, 17 Apr 2026 18:45:32 -0400 Subject: [PATCH 02/18] [codex] Upgrade rules_rs and llvm to latest BCR versions (#18397) ## Why This branch brings the Bazel module pins for `rules_rs` and `llvm` up to the latest BCR releases and aligns the root direct dependencies with the versions the module graph already resolves to. That gives us a few concrete wins: - picks up newer upstream fixes in the `rules_rs` / `rules_rust` stack, including work around repo-rule nondeterminism and default Cargo binary target generation - picks up test sharding support from the newer `rules_rust` stack ([hermeticbuild/rules_rust#13](https://github.com/hermeticbuild/rules_rust/pull/13)) - picks up newer built-in knowledge for common system crates like `gio-sys`, `glib-sys`, `gobject-sys`, `libgit2-sys`, and `libssh2-sys`, which gives us a future path to reduce custom build-script handling - reduces local patch maintenance by dropping fixes that are now upstream and rebasing the remaining Windows patch stack onto a newer upstream base - removes the direct-dependency warnings from `bazel-lock-check` by making the root pins match the resolved graph ## What Changed - bump `rules_rs` from `0.0.43` to `0.0.58` - bump `llvm` from `0.6.8` to `0.7.1` - bump `bazel_skylib` from `1.8.2` to `1.9.0` so the root direct dep matches the resolved graph - regenerate `MODULE.bazel.lock` for the updated module graph - refresh the remaining Windows-specific patch stack against the newer upstream sources: - `patches/rules_rs_windows_gnullvm_exec.patch` - `patches/rules_rs_windows_exec_linker.patch` - `patches/rules_rust_windows_exec_std.patch` - `patches/rules_rust_windows_msvc_direct_link_args.patch` - remove patches that are no longer needed because the underlying fixes are upstream now: - `patches/rules_rs_delete_git_worktree_pointer.patch` - `patches/rules_rust_repository_set_exec_constraints.patch` ## Validation - `just bazel-lock-update` - `just bazel-lock-check` --------- Co-authored-by: Codex --- MODULE.bazel | 12 +-- MODULE.bazel.lock | 97 ++++++++++++++---- patches/BUILD.bazel | 1 - ...rules_rs_delete_git_worktree_pointer.patch | 20 ---- patches/rules_rs_windows_exec_linker.patch | 35 +++---- patches/rules_rs_windows_gnullvm_exec.patch | 99 +++++++++++++++---- ...rust_repository_set_exec_constraints.patch | 26 ----- patches/rules_rust_windows_exec_std.patch | 47 +++++---- ...s_rust_windows_msvc_direct_link_args.patch | 58 ++++++----- 9 files changed, 230 insertions(+), 165 deletions(-) delete mode 100644 patches/rules_rs_delete_git_worktree_pointer.patch delete mode 100644 patches/rules_rust_repository_set_exec_constraints.patch diff --git a/MODULE.bazel b/MODULE.bazel index 04c5c69ebd..be00937946 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,8 +1,8 @@ module(name = "codex") -bazel_dep(name = "bazel_skylib", version = "1.8.2") +bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "llvm", version = "0.6.8") +bazel_dep(name = "llvm", version = "0.7.1") # The upstream LLVM archive contains a few unix-only symlink entries and is # missing a couple of MinGW compatibility archives that windows-gnullvm needs # during extraction and linking, so patch it until upstream grows native support. @@ -78,8 +78,8 @@ use_repo(osx, "macos_sdk") bazel_dep(name = "apple_support", version = "2.1.0") bazel_dep(name = "rules_cc", version = "0.2.16") bazel_dep(name = "rules_platform", version = "0.1.0") -bazel_dep(name = "rules_rs", version = "0.0.43") -# `rules_rs` 0.0.43 does not model `windows-gnullvm` as a distinct Windows exec +bazel_dep(name = "rules_rs", version = "0.0.58") +# `rules_rs` still does not model `windows-gnullvm` as a distinct Windows exec # platform, so patch it until upstream grows that support for both x86_64 and # aarch64. single_version_override( @@ -87,10 +87,9 @@ single_version_override( patch_strip = 1, patches = [ "//patches:rules_rs_windows_gnullvm_exec.patch", - "//patches:rules_rs_delete_git_worktree_pointer.patch", "//patches:rules_rs_windows_exec_linker.patch", ], - version = "0.0.43", + version = "0.0.58", ) rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust") @@ -108,7 +107,6 @@ rules_rust.patch( "//patches:rules_rust_windows_exec_bin_target.patch", "//patches:rules_rust_windows_exec_std.patch", "//patches:rules_rust_windows_exec_rustc_dev_rlib.patch", - "//patches:rules_rust_repository_set_exec_constraints.patch", ], strip = 1, ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7a1acc3816..d58736aed5 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -41,12 +41,12 @@ "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", - "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc", "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", - "https://bcr.bazel.build/modules/bazel_features/1.42.0/source.json": "f2ea90e5dd0322481147114c7d5e4608c4b3fae2eeccae655e4d76a382389f6f", + "https://bcr.bazel.build/modules/bazel_features/1.45.0/MODULE.bazel": "7daec6d87ab0703417486d4cb948af0b06f55d4d7c08cbb5978c80e79b538edf", + "https://bcr.bazel.build/modules/bazel_features/1.45.0/source.json": "635e4536e09ff125b8972e0fa239c135fde5f18701f7d5115680560651dfb41d", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", @@ -65,7 +65,8 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0", "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d", "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484", "https://bcr.bazel.build/modules/bzip2/1.0.8.bcr.3/MODULE.bazel": "29ecf4babfd3c762be00d7573c288c083672ab60e79c833ff7f49ee662e54471", @@ -85,15 +86,14 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/MODULE.bazel": "7c034d7a4d92b2293294934377f5d1cbc88119710a11079fa8142120f6f08768", "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", - "https://bcr.bazel.build/modules/llvm/0.6.7/MODULE.bazel": "d37a2e10571864dc6a5bb53c29216d90b9400bbcadb422337f49107fd2eaf0d2", - "https://bcr.bazel.build/modules/llvm/0.6.8/MODULE.bazel": "53468e4a4be409c2d34e5b7331d2e1fef982151b777655ca3c0047225b333629", - "https://bcr.bazel.build/modules/llvm/0.6.8/source.json": "b673af466f716e01d6243f59e47729e99f37dc5e17026d2bf18c98206f09b6c5", + "https://bcr.bazel.build/modules/llvm/0.7.0/MODULE.bazel": "3c07a4e5734b0ad41fe24dedaacbf3a35ce4377b7e1a21f24488a0c9ac4f1e6b", + "https://bcr.bazel.build/modules/llvm/0.7.1/MODULE.bazel": "74ac75efc6385b8a95d83bfa36ad399500f747c3d0f50287f9b6f9e854ec4814", + "https://bcr.bazel.build/modules/llvm/0.7.1/source.json": "0cac59d04dafa0ca1f70ea21cf1569e034ef8736c2c7510e834a9e8141d7e631", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", - "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", - "https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4", - "https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85", + "https://bcr.bazel.build/modules/package_metadata/0.0.7/MODULE.bazel": "7adb03933fc8401f495800cf4eafcff0edc6da0ff55c7db223ef69d19f689486", + "https://bcr.bazel.build/modules/package_metadata/0.0.7/source.json": "50639625e937b56115012674c797cca7a05a96b4878c87d803c13dc2b31de8a0", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", @@ -113,7 +113,8 @@ "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", - "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", + "https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/MODULE.bazel": "74e541b0ba877813da786a11707d4e394433c157841d5111a36be0d44b907931", + "https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/source.json": "fc174b3d6215aa14197d1bd779f98bb72d9fd666ee5ec0d6bba6ae986baa4535", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", @@ -125,7 +126,6 @@ "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", - "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", @@ -201,8 +201,8 @@ "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", - "https://bcr.bazel.build/modules/rules_rs/0.0.43/MODULE.bazel": "7adfc2a97d90218ebeb9882de9eb18d9c6b0b41d2884be6ab92c9daadb17c78d", - "https://bcr.bazel.build/modules/rules_rs/0.0.43/source.json": "c315361abf625411f506ab935e660f49f14dc64fa30c125ca0a177c34cd63a2a", + "https://bcr.bazel.build/modules/rules_rs/0.0.58/MODULE.bazel": "72269bad768fbf5e00ea20d1fc5ad2193667d290c2c0ebefe80e787ef416d2a7", + "https://bcr.bazel.build/modules/rules_rs/0.0.58/source.json": "fdc6d257b388c4a0671563e259bea98b3401e86f2df884d16883922d91ffc668", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", @@ -212,7 +212,6 @@ "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", - "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", "https://bcr.bazel.build/modules/sed/4.9.bcr.3/MODULE.bazel": "3aca45895b85b6ef65366cc12a45217ba6870f8931d2d62e09c99c772d9736ab", "https://bcr.bazel.build/modules/sed/4.9.bcr.3/source.json": "31c0cf4c135ed3fa58298cd7bcfd4301c54ea4cf59d7c4e2ea0a180ce68eb34f", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", @@ -222,7 +221,6 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", - "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/tar.bzl/0.9.0/MODULE.bazel": "452a22d7f02b1c9d7a22ab25edf20f46f3e1101f0f67dc4bfbf9a474ddf02445", "https://bcr.bazel.build/modules/tar.bzl/0.9.0/source.json": "c732760a374831a2cf5b08839e4be75017196b4d796a5aa55235272ee17cd839", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", @@ -243,7 +241,7 @@ "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { "general": { "bzlTransitiveDigest": "dnnhvKMf9MIXMulhbhHBblZdDAfAkiSVjApIXpUz9Y8=", - "usagesDigest": "aAcu2vTLy2HUXbcYIow0P6OHLLog/f5FFk8maEC/fpQ=", + "usagesDigest": "7RqoFfyAk9gopozpmkZeHxhtuqELOLYzmqQ4uuF30BU=", "recordedInputs": [ "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" @@ -266,7 +264,7 @@ "googletest": "1.17.0", "jsoncpp": "1.9.6", "libcap": "2.27.bcr.1", - "llvm": "0.6.7", + "llvm": "0.6.8", "nlohmann_json": "3.6.1", "openssl": "3.5.4.bcr.0", "package_metadata": "0.0.5", @@ -286,15 +284,15 @@ "rules_platform": "0.1.0", "rules_proto": "7.1.0", "rules_python": "1.7.0", - "rules_rs": "0.0.40", + "rules_rs": "0.0.43", "rules_shell": "0.6.1", "rules_swift": "3.1.2", "sed": "4.9.bcr.3", "stardoc": "0.7.2", "swift_argument_parser": "1.3.1.2", "tar.bzl": "0.9.0", - "toolchains_llvm_bootstrapped": "0.5.2", "with_cfg.bzl": "0.12.0", + "xz": "5.4.5.bcr.8", "zlib": "1.3.1.bcr.8", "zstd": "1.5.7" } @@ -303,6 +301,21 @@ } } }, + "@@protobuf+//python/dist:system_python.bzl%system_python_extension": { + "general": { + "bzlTransitiveDigest": "pmsA+awieucfllLc2n7k8xEoPp0i5LF9Hw6mGX0cqSQ=", + "usagesDigest": "A+RWmbKdBBwZcBbNGNvfPbqG2vYZRjVrFp6x1iRUrAk=", + "recordedInputs": [], + "generatedRepoSpecs": { + "system_python": { + "repoRuleId": "@@protobuf+//python/dist:system_python.bzl%system_python", + "attributes": { + "minimum_python_version": "3.9" + } + } + } + } + }, "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { "general": { "bzlTransitiveDigest": "06cynZ1bCvvy8zHPrrDlXq+Z68xmjctHpfFxi+zEpJY=", @@ -609,7 +622,9 @@ "annotate-snippets_0.9.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"difference\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.5\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"},{\"name\":\"yansi-term\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"yansi-term\",\"req\":\"^0.1\"}],\"features\":{\"color\":[\"yansi-term\"],\"default\":[]}}", "ansi-to-tui_7.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"nom\",\"req\":\"^7.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"name\":\"simdutf8\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"const_generics\"],\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tui\",\"package\":\"ratatui\",\"req\":\"^0.29\"}],\"features\":{\"default\":[\"zero-copy\",\"simd\"],\"simd\":[\"dep:simdutf8\"],\"zero-copy\":[]}}", "anstream_0.6.21": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"name\":\"anstyle-parse\",\"req\":\"^0.2.0\"},{\"name\":\"anstyle-query\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle-wincon\",\"optional\":true,\"req\":\"^3.0.5\",\"target\":\"cfg(windows)\"},{\"name\":\"colorchoice\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.16\"},{\"name\":\"is_terminal_polyfill\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"owo-colors\",\"req\":\"^4.0.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"strip-ansi-escapes\",\"req\":\"^0.2.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2.2\"}],\"features\":{\"auto\":[\"dep:anstyle-query\"],\"default\":[\"auto\",\"wincon\"],\"test\":[],\"wincon\":[\"dep:anstyle-wincon\"]}}", + "anstream_1.0.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"name\":\"anstyle-parse\",\"req\":\"^1.0.0\"},{\"name\":\"anstyle-query\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle-wincon\",\"optional\":true,\"req\":\"^3.0.5\",\"target\":\"cfg(windows)\"},{\"name\":\"colorchoice\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.16\"},{\"name\":\"is_terminal_polyfill\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"owo-colors\",\"req\":\"^4.0.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"strip-ansi-escapes\",\"req\":\"^0.2.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2.2\"}],\"features\":{\"auto\":[\"dep:anstyle-query\"],\"default\":[\"auto\",\"wincon\"],\"test\":[],\"wincon\":[\"dep:anstyle-wincon\"]}}", "anstyle-parse_0.2.7": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.7.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"codegenrs\",\"req\":\"^3.0.1\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.5\"},{\"name\":\"utf8parse\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"vte_generate_state_changes\",\"req\":\"^0.1.1\"}],\"features\":{\"core\":[\"dep:arrayvec\"],\"default\":[\"utf8\"],\"utf8\":[\"dep:utf8parse\"]}}", + "anstyle-parse_1.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.7.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"codegenrs\",\"req\":\"^3.0.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.16\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.23\"},{\"name\":\"utf8parse\",\"optional\":true,\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"vte_generate_state_changes\",\"req\":\"^0.1.2\"}],\"features\":{\"core\":[\"dep:arrayvec\"],\"default\":[\"utf8\"],\"utf8\":[\"dep:utf8parse\"]}}", "anstyle-query_1.1.5": "{\"dependencies\":[{\"features\":[\"Win32_System_Console\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.60.2, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "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\":[]}}", @@ -647,7 +662,9 @@ "autocfg_1.5.0": "{\"dependencies\":[],\"features\":{}}", "aws-lc-rs_1.16.2": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.39.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", "aws-lc-sys_0.39.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", + "axum-core_0.4.5": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.7.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", "axum-core_0.5.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", + "axum_0.7.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"name\":\"axum-core\",\"req\":\"^0.4.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.4.2\"},{\"features\":[\"__private\"],\"kind\":\"dev\",\"name\":\"axum-macros\",\"req\":\"^0.4.1\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"^0.7\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.24.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.24.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.1\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.1\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:serde_urlencoded\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:serde_urlencoded\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", "axum_0.8.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"axum-core\",\"req\":\"^0.5.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"client\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"=0.8.4\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.211\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.44\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.44.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.28.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.28.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private\":[\"tokio\",\"http1\",\"dep:reqwest\"],\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:serde\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", "backtrace_0.3.76": "{\"dependencies\":[{\"default_features\":false,\"name\":\"addr2line\",\"req\":\"^0.25.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"cpp_demangle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.156\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libloading\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"miniz_oxide\",\"req\":\"^0.8\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"default_features\":false,\"features\":[\"read_core\",\"elf\",\"macho\",\"pe\",\"xcoff\",\"unaligned\",\"archive\"],\"name\":\"object\",\"req\":\"^0.37.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"rustc-demangle\",\"req\":\"^0.1.24\"},{\"default_features\":false,\"name\":\"ruzstd\",\"optional\":true,\"req\":\"^0.8.1\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(any(windows, target_os = \\\"cygwin\\\"))\"}],\"features\":{\"coresymbolication\":[],\"dbghelp\":[],\"default\":[\"std\"],\"dl_iterate_phdr\":[],\"dladdr\":[],\"kernel32\":[],\"libunwind\":[],\"ruzstd\":[\"dep:ruzstd\"],\"serialize-serde\":[\"serde\"],\"std\":[],\"unix-backtrace\":[]}}", "base64_0.21.7": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -706,10 +723,14 @@ "cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}", "clang-sys_1.8.1": "{\"dependencies\":[{\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"build\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.39\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\">=3.0.0, <3.7.0\"}],\"features\":{\"clang_10_0\":[\"clang_9_0\"],\"clang_11_0\":[\"clang_10_0\"],\"clang_12_0\":[\"clang_11_0\"],\"clang_13_0\":[\"clang_12_0\"],\"clang_14_0\":[\"clang_13_0\"],\"clang_15_0\":[\"clang_14_0\"],\"clang_16_0\":[\"clang_15_0\"],\"clang_17_0\":[\"clang_16_0\"],\"clang_18_0\":[\"clang_17_0\"],\"clang_3_5\":[],\"clang_3_6\":[\"clang_3_5\"],\"clang_3_7\":[\"clang_3_6\"],\"clang_3_8\":[\"clang_3_7\"],\"clang_3_9\":[\"clang_3_8\"],\"clang_4_0\":[\"clang_3_9\"],\"clang_5_0\":[\"clang_4_0\"],\"clang_6_0\":[\"clang_5_0\"],\"clang_7_0\":[\"clang_6_0\"],\"clang_8_0\":[\"clang_7_0\"],\"clang_9_0\":[\"clang_8_0\"],\"libcpp\":[],\"runtime\":[\"libloading\"],\"static\":[]}}", "clap_4.5.58": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.58\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.55\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", + "clap_4.6.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.6.0\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.6.0\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.23\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.22\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.27\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^1.1.1\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", "clap_builder_4.5.58": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.7\"},{\"name\":\"anstyle\",\"req\":\"^1.0.8\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.73\"},{\"name\":\"clap_lex\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.6.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}", + "clap_builder_4.6.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle\",\"req\":\"^1.0.13\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.76\"},{\"name\":\"clap_lex\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.1\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.9.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.2\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}", "clap_complete_4.5.65": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"default_features\":false,\"features\":[\"std\",\"derive\",\"help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"name\":\"clap_lex\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"completest\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"completest-pty\",\"optional\":true,\"req\":\"^0.5.5\"},{\"name\":\"is_executable\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"shlex\",\"optional\":true,\"req\":\"^1.3.0\"},{\"features\":[\"diff\",\"dir\",\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.1\"}],\"features\":{\"debug\":[\"clap/debug\"],\"default\":[],\"unstable-doc\":[\"unstable-dynamic\"],\"unstable-dynamic\":[\"dep:clap_lex\",\"dep:shlex\",\"dep:is_executable\",\"clap/unstable-ext\"],\"unstable-shell-tests\":[\"dep:completest\",\"dep:completest-pty\"]}}", "clap_derive_4.5.55": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.10\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"name\":\"quote\",\"req\":\"^1.0.9\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.8\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}", + "clap_derive_4.6.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.13\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.1\"},{\"name\":\"quote\",\"req\":\"^1.0.45\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.117\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}", "clap_lex_1.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"}],\"features\":{}}", + "clap_lex_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"}],\"features\":{}}", "clipboard-win_5.4.1": "{\"dependencies\":[{\"name\":\"error-code\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-win\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"monitor\":[\"windows-win\"],\"std\":[\"error-code/std\"]}}", "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", "cmp_any_0.8.1": "{\"dependencies\":[],\"features\":{}}", @@ -836,7 +857,9 @@ "enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}", "env_filter_1.0.0": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", + "env_filter_1.0.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.12.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", "env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}", + "env_logger_0.11.10": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"wincon\"],\"name\":\"anstream\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"env_filter\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.22\"},{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"}],\"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\"]}}", "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\":[]}}", @@ -880,15 +903,20 @@ "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", "fslock_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.66\",\"target\":\"cfg(unix)\"},{\"features\":[\"minwindef\",\"minwinbase\",\"winbase\",\"errhandlingapi\",\"winerror\",\"winnt\",\"synchapi\",\"handleapi\",\"fileapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "futures-channel_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", + "futures-channel_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", "futures-core_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", + "futures-core_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-executor_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"name\":\"num_cpus\",\"optional\":true,\"req\":\"^1.8.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\",\"num_cpus\"]}}", "futures-intrusive_0.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"crossbeam\",\"req\":\"^0.7\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"lock_api\",\"req\":\"^0.4.1\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.1.11\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"parking_lot\"]}}", "futures-io_0.3.31": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", "futures-lite_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.5\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.3.3\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"spin_on\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"race\",\"std\"],\"race\":[\"fastrand\"],\"std\":[\"alloc\",\"fastrand/std\",\"futures-io\",\"parking\"]}}", "futures-macro_0.3.31": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "futures-sink_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "futures-sink_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "futures-task_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", + "futures-task_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-util_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", + "futures-util_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.26\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"default_features\":false,\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"spin\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"slab\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\",\"libc\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\",\"libc\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab/std\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-channel\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-io\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"futures-sink/alloc\",\"futures-channel/alloc\",\"futures-util/alloc\"],\"async-await\":[\"futures-util/async-await\",\"futures-util/async-await-macro\"],\"bilock\":[\"futures-util/bilock\"],\"cfg-target-has-atomic\":[],\"compat\":[\"std\",\"futures-util/compat\"],\"default\":[\"std\",\"async-await\",\"executor\"],\"executor\":[\"std\",\"futures-executor/std\"],\"io-compat\":[\"compat\",\"futures-util/io-compat\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"futures-io/std\",\"futures-sink/std\",\"futures-util/std\",\"futures-util/io\",\"futures-util/channel\"],\"thread-pool\":[\"executor\",\"futures-executor/thread-pool\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\",\"futures-channel/unstable\",\"futures-io/unstable\",\"futures-util/unstable\"],\"write-all-vectored\":[\"futures-util/write-all-vectored\"]}}", "fxhash_0.2.1": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"}],\"features\":{}}", "generator_0.8.8": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.100\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"name\":\"windows-link\",\"req\":\">=0.1, <=0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-result\",\"req\":\">=0.3.1, <=0.4\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -923,6 +951,7 @@ "gobject-sys_0.21.5": "{\"dependencies\":[{\"name\":\"glib-sys\",\"req\":\"^0.21\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"shell-words\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"system-deps\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"v2_58\":[],\"v2_62\":[\"v2_58\"],\"v2_66\":[\"v2_62\"],\"v2_68\":[\"v2_66\"],\"v2_70\":[\"v2_68\"],\"v2_72\":[\"v2_70\"],\"v2_74\":[\"v2_72\"],\"v2_76\":[\"v2_74\"],\"v2_78\":[\"v2_74\"],\"v2_80\":[\"v2_78\"],\"v2_82\":[\"v2_80\"],\"v2_84\":[\"v2_82\"],\"v2_86\":[\"v2_84\"]}}", "gzip-header_1.0.0": "{\"dependencies\":[{\"name\":\"crc32fast\",\"req\":\"^1.2.1\"}],\"features\":{}}", "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\":[]}}", "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\":[]}}", @@ -1009,6 +1038,7 @@ "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\"]}}", "itertools_0.14.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"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\"]}}", "itoa_1.0.17": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", + "itoa_1.0.18": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", "ixdtf_0.6.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"serde-json-core\",\"req\":\"^0.6.0\"}],\"features\":{\"default\":[\"duration\"],\"duration\":[]}}", "jiff-static_0.2.18": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", "jiff-static_0.2.23": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", @@ -1062,6 +1092,7 @@ "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\":[]}}", "maplit_1.0.2": "{\"dependencies\":[],\"features\":{}}", "matchers_0.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"syntax\",\"dfa-build\",\"dfa-search\"],\"name\":\"regex-automata\",\"req\":\"^0.4\"}],\"features\":{\"unicode\":[\"regex-automata/unicode\"]}}", + "matchit_0.7.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "matchit_0.9.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"wayfind\",\"req\":\"^0.8\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "md-5_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"md5-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"md5-asm\"],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", @@ -1174,9 +1205,11 @@ "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\":{}}", "pin-project-lite_0.2.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-project_1.1.10": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.10\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", + "pin-project_1.1.11": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.11\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-utils_0.1.0": "{\"dependencies\":[],\"features\":{}}", "piper_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"async-executor\",\"req\":\"^1.5.1\"},{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.0.0\"},{\"name\":\"atomic-waker\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"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\"}],\"features\":{\"default\":[\"std\"],\"portable-atomic\":[\"atomic-waker/portable-atomic\",\"portable_atomic_crate\",\"portable-atomic-util\"],\"std\":[\"fastrand/std\",\"futures-io\"]}}", "pkcs1_0.7.5": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\",\"pkcs8?/alloc\"],\"pem\":[\"alloc\",\"der/pem\",\"pkcs8?/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", @@ -1188,6 +1221,7 @@ "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "poly1305_0.8.0": "{\"dependencies\":[{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", "portable-atomic-util_0.2.5": "{\"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-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\"]}}", "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\"]}}", @@ -1206,13 +1240,19 @@ "process-wrap_9.0.1": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"fs\",\"poll\",\"signal\"],\"name\":\"nix\",\"optional\":true,\"req\":\"^0.30.1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"remoteprocess\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.20.0\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38.2\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38.2\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"name\":\"windows\",\"optional\":true,\"req\":\"^0.62.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"creation-flags\":[\"dep:windows\",\"windows/Win32_System_Threading\"],\"default\":[\"creation-flags\",\"job-object\",\"kill-on-drop\",\"process-group\",\"process-session\",\"tracing\"],\"job-object\":[\"dep:windows\",\"windows/Win32_Security\",\"windows/Win32_System_Diagnostics_ToolHelp\",\"windows/Win32_System_IO\",\"windows/Win32_System_JobObjects\",\"windows/Win32_System_Threading\"],\"kill-on-drop\":[],\"process-group\":[],\"process-session\":[\"process-group\"],\"reset-sigmask\":[],\"std\":[\"dep:nix\"],\"tokio1\":[\"dep:nix\",\"dep:futures\",\"dep:tokio\"],\"tracing\":[\"dep:tracing\"]}}", "proptest_1.9.0": "{\"dependencies\":[{\"name\":\"bit-set\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bitflags\",\"req\":\"^2.9\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.15\"},{\"name\":\"proptest-macro\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"req\":\"^0.9\"},{\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rusty-fork\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.112\"},{\"name\":\"unarray\",\"req\":\"^0.1.4\"},{\"name\":\"x86\",\"optional\":true,\"req\":\"^0.52.0\"}],\"features\":{\"alloc\":[],\"atomic64bit\":[],\"attr-macro\":[\"proptest-macro\"],\"bit-set\":[\"dep:bit-set\",\"dep:bit-vec\"],\"default\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"default-code-coverage\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"fork\":[\"std\",\"rusty-fork\",\"tempfile\"],\"handle-panics\":[\"std\"],\"hardware-rng\":[\"x86\"],\"no_std\":[\"num-traits/libm\"],\"std\":[\"rand/std\",\"rand/os_rng\",\"regex-syntax\",\"num-traits/std\"],\"timeout\":[\"fork\",\"rusty-fork/timeout\"],\"unstable\":[]}}", "prost-build_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"name\":\"once_cell\",\"req\":\"^1.17.1\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.9.1\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^10.0.1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", + "prost-build_0.13.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.13\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"name\":\"once_cell\",\"req\":\"^1.17.1\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.9.1\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^10.0.1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", "prost-build_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.14\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"petgraph\",\"req\":\"^0.8\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^22\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", "prost-derive_0.12.6": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "prost-derive_0.13.1": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.13\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "prost-derive_0.14.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "prost-types_0.12.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"prost-derive\"],\"name\":\"prost\",\"req\":\"^0.12.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"prost/std\"]}}", + "prost-types_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"prost-derive\"],\"name\":\"prost\",\"req\":\"^0.13.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"prost/std\"]}}", "prost-types_0.14.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"prost\",\"req\":\"^0.14.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"std\":[\"prost/std\"]}}", "prost_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.12.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}", + "prost_0.13.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.13.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}", "prost_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"std\":[]}}", + "protoc-gen-prost_0.4.0": "{\"dependencies\":[{\"name\":\"once_cell\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"prost-build\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"regex\",\"req\":\"^1.5.5\"}],\"features\":{}}", + "protoc-gen-tonic_0.4.1": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"prettyplease\",\"req\":\"^0.2.9\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"prost-build\",\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.13.1\"},{\"name\":\"protoc-gen-prost\",\"req\":\"^0.4.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"regex\",\"req\":\"^1.5.5\"},{\"features\":[\"parsing\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.22\"},{\"name\":\"tonic-build\",\"req\":\"^0.12.0\"}],\"features\":{}}", "psl-types_2.0.11": "{\"dependencies\":[],\"features\":{}}", "psl_2.1.184": "{\"dependencies\":[{\"name\":\"psl-types\",\"req\":\"^2.0.11\"},{\"kind\":\"dev\",\"name\":\"rspec\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"helpers\"],\"helpers\":[]}}", "pulldown-cmark-escape_0.10.1": "{\"dependencies\":[],\"features\":{\"simd\":[]}}", @@ -1372,6 +1412,7 @@ "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\":[]}}", "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.8.6": "{\"dependencies\":[{\"name\":\"async-io\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"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.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"name\":\"hashlink\",\"req\":\"^0.10.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"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.4.1\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.15\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.132\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.73\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"sqlite\",\"mysql\",\"migrate\",\"macros\",\"time\",\"uuid\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"async-io\"],\"_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\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"]}}", @@ -1448,12 +1489,16 @@ "tinyvec_1.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"generic-array\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"tinyvec_macros\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"alloc\":[\"tinyvec_macros\"],\"debugger_visualizer\":[],\"default\":[],\"experimental_write_impl\":[],\"grab_spare_slice\":[],\"latest_stable_rust\":[\"rustc_1_61\"],\"nightly_slice_partition_dedup\":[],\"real_blackbox\":[\"criterion/real_blackbox\"],\"rustc_1_40\":[],\"rustc_1_55\":[],\"rustc_1_57\":[],\"rustc_1_61\":[\"rustc_1_57\"],\"std\":[\"alloc\"]}}", "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-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\"]}}", "tokio-stream_0.1.18": "{\"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\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"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\"]}}", "tokio-test_0.4.5": "{\"dependencies\":[{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}", + "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\":[]}}", "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\":[]}}", @@ -1464,13 +1509,16 @@ "toml_parser_1.0.6+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"anstream\",\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"default_features\":false,\"name\":\"winnow\",\"req\":\"^0.7.13\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\"],\"simd\":[\"winnow/simd\"],\"std\":[\"alloc\"],\"unsafe\":[]}}", "toml_parser_1.0.9+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"anstream\",\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"default_features\":false,\"name\":\"winnow\",\"req\":\"^0.7.13\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\"],\"simd\":[\"winnow/simd\"],\"std\":[\"alloc\"],\"unsafe\":[]}}", "toml_writer_1.0.6+spec-1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml_old\",\"package\":\"toml\",\"req\":\"^0.5.11\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "tonic-build_0.12.3": "{\"dependencies\":[{\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"prost-build\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"prost-types\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"cleanup-markdown\":[\"prost\",\"prost-build/cleanup-markdown\"],\"default\":[\"transport\",\"prost\"],\"prost\":[\"prost-build\",\"dep:prost-types\"],\"transport\":[]}}", "tonic-build_0.14.3": "{\"dependencies\":[{\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"default\":[\"transport\"],\"transport\":[]}}", "tonic-prost-build_0.14.3": "{\"dependencies\":[{\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"prost-build\",\"req\":\"^0.14\"},{\"name\":\"prost-types\",\"req\":\"^0.14\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"tempfile\",\"req\":\"^3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tonic\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"tonic-build\",\"req\":\"^0.14.0\"}],\"features\":{\"cleanup-markdown\":[\"prost-build/cleanup-markdown\"],\"default\":[\"transport\",\"cleanup-markdown\"],\"transport\":[\"tonic-build/transport\"]}}", "tonic-prost_0.14.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"prost\",\"req\":\"^0.14\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"tonic\",\"req\":\"^0.14.0\"}],\"features\":{}}", + "tonic_0.12.1": "{\"dependencies\":[{\"name\":\"async-stream\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"name\":\"axum\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"hyper-timeout\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^2.0\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"logging\",\"tls12\",\"ring\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"tower\",\"optional\":true,\"req\":\"^0.4.7\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4.7\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13.0\"}],\"features\":{\"channel\":[\"dep:hyper\",\"hyper?/client\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"dep:tower\",\"tower?/balance\",\"tower?/buffer\",\"tower?/discover\",\"tower?/limit\",\"dep:tokio\",\"tokio?/time\",\"dep:hyper-timeout\"],\"codegen\":[\"dep:async-trait\"],\"default\":[\"transport\",\"codegen\",\"prost\"],\"gzip\":[\"dep:flate2\"],\"prost\":[\"dep:prost\"],\"router\":[\"dep:axum\",\"dep:tower\",\"tower?/util\"],\"server\":[\"router\",\"dep:async-stream\",\"dep:h2\",\"dep:hyper\",\"hyper?/server\",\"dep:hyper-util\",\"hyper-util?/service\",\"hyper-util?/server-auto\",\"dep:socket2\",\"dep:tokio\",\"tokio?/macros\",\"tokio?/net\",\"tokio?/time\",\"tokio-stream/net\",\"dep:tower\",\"tower?/util\",\"tower?/limit\"],\"tls\":[\"dep:rustls-pemfile\",\"dep:tokio-rustls\",\"dep:tokio\",\"tokio?/rt\",\"tokio?/macros\"],\"tls-roots\":[\"tls\",\"channel\",\"dep:rustls-native-certs\"],\"tls-webpki-roots\":[\"tls\",\"channel\",\"dep:webpki-roots\"],\"transport\":[\"server\",\"channel\"],\"zstd\":[\"dep:zstd\"]}}", "tonic_0.14.3": "{\"dependencies\":[{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"hyper-timeout\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.11\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.0\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"logging\",\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.1\"},{\"default_features\":false,\"name\":\"tokio-stream\",\"req\":\"^0.1.16\"},{\"default_features\":false,\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"load-shed\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13.0\"}],\"features\":{\"_tls-any\":[\"dep:tokio\",\"tokio?/rt\",\"tokio?/macros\",\"tls-connect-info\"],\"channel\":[\"dep:hyper\",\"hyper?/client\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"dep:tower\",\"tower?/balance\",\"tower?/buffer\",\"tower?/discover\",\"tower?/limit\",\"tower?/load-shed\",\"tower?/util\",\"dep:tokio\",\"tokio?/time\",\"dep:hyper-timeout\"],\"codegen\":[\"dep:async-trait\"],\"default\":[\"router\",\"transport\",\"codegen\"],\"deflate\":[\"dep:flate2\"],\"gzip\":[\"dep:flate2\"],\"router\":[\"dep:axum\",\"dep:tower\",\"tower?/util\"],\"server\":[\"dep:h2\",\"dep:hyper\",\"hyper?/server\",\"dep:hyper-util\",\"hyper-util?/service\",\"hyper-util?/server-auto\",\"dep:socket2\",\"dep:tokio\",\"tokio?/macros\",\"tokio?/net\",\"tokio?/time\",\"tokio-stream/net\",\"dep:tower\",\"tower?/util\",\"tower?/limit\",\"tower?/load-shed\"],\"tls-aws-lc\":[\"_tls-any\",\"tokio-rustls/aws-lc-rs\"],\"tls-connect-info\":[\"dep:tokio-rustls\"],\"tls-native-roots\":[\"_tls-any\",\"channel\",\"dep:rustls-native-certs\"],\"tls-ring\":[\"_tls-any\",\"tokio-rustls/ring\"],\"tls-webpki-roots\":[\"_tls-any\",\"channel\",\"dep:webpki-roots\"],\"transport\":[\"server\",\"channel\"],\"zstd\":[\"dep:zstd\"]}}", "tower-http_0.6.8": "{\"dependencies\":[{\"features\":[\"tokio\"],\"name\":\"async-compression\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bitflags\",\"req\":\"^2.0.2\"},{\"kind\":\"dev\",\"name\":\"brotli\",\"req\":\"^8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.14\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.14\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"http-range-header\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"client-legacy\",\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"iri-string\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.17\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"sync_wrapper\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"buffer\",\"util\",\"retry\",\"make\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"zstd\",\"req\":\"^0.13\"}],\"features\":{\"add-extension\":[],\"auth\":[\"base64\",\"validate-request\"],\"catch-panic\":[\"tracing\",\"futures-util/std\",\"dep:http-body\",\"dep:http-body-util\"],\"compression-br\":[\"async-compression/brotli\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"compression-deflate\":[\"async-compression/zlib\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"compression-full\":[\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"compression-zstd\"],\"compression-gzip\":[\"async-compression/gzip\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"compression-zstd\":[\"async-compression/zstd\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"cors\":[],\"decompression-br\":[\"async-compression/brotli\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"decompression-deflate\":[\"async-compression/zlib\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"decompression-full\":[\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"decompression-zstd\"],\"decompression-gzip\":[\"async-compression/gzip\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"decompression-zstd\":[\"async-compression/zstd\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"default\":[],\"follow-redirect\":[\"futures-util\",\"dep:http-body\",\"iri-string\",\"tower/util\"],\"fs\":[\"futures-core\",\"futures-util\",\"dep:http-body\",\"dep:http-body-util\",\"tokio/fs\",\"tokio-util/io\",\"tokio/io-util\",\"dep:http-range-header\",\"mime_guess\",\"mime\",\"percent-encoding\",\"httpdate\",\"set-status\",\"futures-util/alloc\",\"tracing\"],\"full\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-full\",\"cors\",\"decompression-full\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"limit\":[\"dep:http-body\",\"dep:http-body-util\"],\"map-request-body\":[],\"map-response-body\":[],\"metrics\":[\"dep:http-body\",\"tokio/time\"],\"normalize-path\":[],\"propagate-header\":[],\"redirect\":[],\"request-id\":[\"uuid\"],\"sensitive-headers\":[],\"set-header\":[],\"set-status\":[],\"timeout\":[\"dep:http-body\",\"tokio/time\"],\"trace\":[\"dep:http-body\",\"tracing\"],\"util\":[\"tower\"],\"validate-request\":[\"mime\"]}}", "tower-layer_0.3.3": "{\"dependencies\":[],\"features\":{}}", "tower-service_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.22\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"features\":[\"macros\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"tower-layer\",\"req\":\"^0.3\"}],\"features\":{}}", + "tower_0.4.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"hdrhistogram\",\"optional\":true,\"req\":\"^7.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"hdrhistogram\",\"req\":\"^7.0\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"pin-project\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"features\":[\"small_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6\"},{\"features\":[\"macros\",\"sync\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.6.2\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.1\"},{\"name\":\"tower-service\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"__common\":[\"futures-core\",\"pin-project-lite\"],\"balance\":[\"discover\",\"load\",\"ready-cache\",\"make\",\"rand\",\"slab\"],\"buffer\":[\"__common\",\"tokio/sync\",\"tokio/rt\",\"tokio-util\",\"tracing\"],\"default\":[\"log\"],\"discover\":[\"__common\"],\"filter\":[\"__common\",\"futures-util\"],\"full\":[\"balance\",\"buffer\",\"discover\",\"filter\",\"hedge\",\"limit\",\"load\",\"load-shed\",\"make\",\"ready-cache\",\"reconnect\",\"retry\",\"spawn-ready\",\"steer\",\"timeout\",\"util\"],\"hedge\":[\"util\",\"filter\",\"futures-util\",\"hdrhistogram\",\"tokio/time\",\"tracing\"],\"limit\":[\"__common\",\"tokio/time\",\"tokio/sync\",\"tokio-util\",\"tracing\"],\"load\":[\"__common\",\"tokio/time\",\"tracing\"],\"load-shed\":[\"__common\"],\"log\":[\"tracing/log\"],\"make\":[\"futures-util\",\"pin-project-lite\",\"tokio/io-std\"],\"ready-cache\":[\"futures-core\",\"futures-util\",\"indexmap\",\"tokio/sync\",\"tracing\",\"pin-project-lite\"],\"reconnect\":[\"make\",\"tokio/io-std\",\"tracing\"],\"retry\":[\"__common\",\"tokio/time\"],\"spawn-ready\":[\"__common\",\"futures-util\",\"tokio/sync\",\"tokio/rt\",\"util\",\"tracing\"],\"steer\":[],\"timeout\":[\"pin-project-lite\",\"tokio/time\"],\"util\":[\"__common\",\"futures-util\",\"pin-project\"]}}", "tower_0.5.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.22\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"async-await-macro\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.22\"},{\"default_features\":false,\"name\":\"hdrhistogram\",\"optional\":true,\"req\":\"^7.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"hdrhistogram\",\"req\":\"^7.0\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.2\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\"},{\"name\":\"sync_wrapper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6.2\"},{\"features\":[\"macros\",\"sync\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3.3\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"balance\":[\"discover\",\"load\",\"ready-cache\",\"make\",\"slab\",\"util\"],\"buffer\":[\"tokio/sync\",\"tokio/rt\",\"tokio-util\",\"tracing\",\"pin-project-lite\"],\"discover\":[\"futures-core\",\"pin-project-lite\"],\"filter\":[\"futures-util\",\"pin-project-lite\"],\"full\":[\"balance\",\"buffer\",\"discover\",\"filter\",\"hedge\",\"limit\",\"load\",\"load-shed\",\"make\",\"ready-cache\",\"reconnect\",\"retry\",\"spawn-ready\",\"steer\",\"timeout\",\"util\"],\"hedge\":[\"util\",\"filter\",\"futures-util\",\"hdrhistogram\",\"tokio/time\",\"tracing\"],\"limit\":[\"tokio/time\",\"tokio/sync\",\"tokio-util\",\"tracing\",\"pin-project-lite\"],\"load\":[\"tokio/time\",\"tracing\",\"pin-project-lite\"],\"load-shed\":[\"pin-project-lite\"],\"log\":[\"tracing/log\"],\"make\":[\"pin-project-lite\",\"tokio\"],\"ready-cache\":[\"futures-core\",\"futures-util\",\"indexmap\",\"tokio/sync\",\"tracing\",\"pin-project-lite\"],\"reconnect\":[\"make\",\"tracing\"],\"retry\":[\"tokio/time\",\"util\"],\"spawn-ready\":[\"futures-util\",\"tokio/sync\",\"tokio/rt\",\"util\",\"tracing\"],\"steer\":[],\"timeout\":[\"pin-project-lite\",\"tokio/time\"],\"tokio-stream\":[],\"util\":[\"futures-core\",\"futures-util\",\"pin-project-lite\",\"sync_wrapper\"]}}", "tracing-appender_0.2.4": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"name\":\"crossbeam-channel\",\"req\":\"^0.5.6\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.2\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"fmt\",\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.18\"}],\"features\":{}}", "tracing-attributes_0.1.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"visit-mut\",\"clone-impls\",\"extra-traits\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.64\"}],\"features\":{\"async-await\":[]}}", @@ -1662,7 +1710,9 @@ "zbus_macros_4.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.3.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"proc-macro-crate\",\"req\":\"^3.1.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.81\"},{\"name\":\"quote\",\"req\":\"^1.0.36\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.200\"},{\"features\":[\"extra-traits\",\"fold\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.64\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.93\"},{\"name\":\"zvariant_utils\",\"req\":\"=2.1.0\"}],\"features\":{}}", "zbus_names_3.0.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"enumflags2\"],\"name\":\"zvariant\",\"req\":\"^4.0.0\"}],\"features\":{}}", "zerocopy-derive_0.8.37": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"dissimilar\",\"req\":\"^1.0.9\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"=0.2.17\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"=1.0.80\"},{\"name\":\"quote\",\"req\":\"^1.0.40\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"=1.0.40\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"visit\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.89\"}],\"features\":{}}", + "zerocopy-derive_0.8.42": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"dissimilar\",\"req\":\"^1.0.9\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"=0.2.17\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.1\"},{\"name\":\"quote\",\"req\":\"^1.0.40\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"visit\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", "zerocopy_0.8.37": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"elain\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.89\"},{\"name\":\"zerocopy-derive\",\"req\":\"=0.8.37\",\"target\":\"cfg(any())\"},{\"name\":\"zerocopy-derive\",\"optional\":true,\"req\":\"=0.8.37\"},{\"kind\":\"dev\",\"name\":\"zerocopy-derive\",\"req\":\"=0.8.37\"}],\"features\":{\"__internal_use_only_features_that_work_on_stable\":[\"alloc\",\"derive\",\"simd\",\"std\"],\"alloc\":[],\"derive\":[\"zerocopy-derive\"],\"float-nightly\":[],\"simd\":[],\"simd-nightly\":[\"simd\"],\"std\":[\"alloc\"]}}", + "zerocopy_0.8.42": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"elain\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"zerocopy-derive\",\"req\":\"=0.8.42\",\"target\":\"cfg(any())\"},{\"name\":\"zerocopy-derive\",\"optional\":true,\"req\":\"=0.8.42\"},{\"kind\":\"dev\",\"name\":\"zerocopy-derive\",\"req\":\"=0.8.42\"}],\"features\":{\"__internal_use_only_features_that_work_on_stable\":[\"alloc\",\"derive\",\"simd\",\"std\"],\"alloc\":[],\"derive\":[\"zerocopy-derive\"],\"float-nightly\":[],\"simd\":[],\"simd-nightly\":[\"simd\"],\"std\":[\"alloc\"]}}", "zerofrom-derive_0.1.6": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", "zerofrom_0.1.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zerofrom-derive\",\"optional\":true,\"req\":\"^0.1.3\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"dep:zerofrom-derive\"]}}", "zeroize_1.8.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"zeroize_derive\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"aarch64\":[],\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"zeroize_derive\"],\"simd\":[],\"std\":[\"alloc\"]}}", @@ -1706,6 +1756,15 @@ "clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f", "clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57", "clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d", + "rust-analyzer-1.93.0-aarch64-apple-darwin.tar.xz": "8a09a46d45277678b2d112eef61736e03e78dfa6c506e187df176b904659e5a7", + "rust-analyzer-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "1fb48fadbaea6da36cfcd6b72122eeaa6ab025c9c82441777cf913108f73c115", + "rust-analyzer-1.93.0-aarch64-pc-windows-msvc.tar.xz": "558100ab62fe22d552be43c1bf166fef057ff2f3eb0d786cf5fe509a08701f37", + "rust-analyzer-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "b2e7890cf5953f572eb05d7328c086fa60d4f16e5d301d35c52b3f58b88a8316", + "rust-analyzer-1.93.0-x86_64-apple-darwin.tar.xz": "3cb7975d4fd17840e2980c9d864755f801ee9e7594de886a5816c73b07594508", + "rust-analyzer-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "4471db6393b7380624899482aae99612fd1cf509ae36d0c68292077c2104127d", + "rust-analyzer-1.93.0-x86_64-pc-windows-msvc.tar.xz": "f4c1f8f120f48974cb1e0cea01a955ce52cdfa0f1db0355c5fb5a2deeca8188a", + "rust-analyzer-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "33fcd377be3b5ffdd95977c3d0219f63725c18b6c8b53fb5be0418962a84738c", + "rust-src-1.93.0.tar.xz": "0e7b9acd5debfeffef3741dc8f6edf137d70426a0027d6b190cc8cfb0a1ac23c", "rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b", "rust-std-1.93.0-aarch64-apple-ios-macabi.tar.xz": "24d47e615ce101869ff452a572a6b77ed7cf70f2454d0b50892ac849e8c7ac4d", "rust-std-1.93.0-aarch64-apple-ios-sim.tar.xz": "d1d5e2d1b79206f2cc9fb7f6a2958cfe0f4bbc9147fda8dbc3608aa4be5e6816", diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index 0924842e48..b58292c727 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -10,7 +10,6 @@ exports_files([ "rules_rust_windows_exec_bin_target.patch", "rules_rust_windows_exec_std.patch", "rules_rust_windows_process_wrapper_skip_temp_outputs.patch", - "rules_rust_repository_set_exec_constraints.patch", "rules_rust_windows_msvc_direct_link_args.patch", "rules_rust_windows_gnullvm_build_script.patch", "rules_rs_windows_gnullvm_exec.patch", diff --git a/patches/rules_rs_delete_git_worktree_pointer.patch b/patches/rules_rs_delete_git_worktree_pointer.patch deleted file mode 100644 index fb453dbe79..0000000000 --- a/patches/rules_rs_delete_git_worktree_pointer.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/rs/private/crate_git_repository.bzl b/rs/private/crate_git_repository.bzl ---- a/rs/private/crate_git_repository.bzl -+++ b/rs/private/crate_git_repository.bzl -@@ -35,6 +35,14 @@ - "HEAD" - ]) - if result.return_code != 0: - fail(result.stderr) - -+ # Remove .git worktree pointer file. It contains an absolute path to -+ # the bazel output base which is machine-specific and non-deterministic. -+ # Leaving it in pollutes compile_data globs and causes AC misses. -+ # -+ # Note that bazelbuild/rules_rust ignores .git (among other paths) during splicing: -+ # https://github.com/bazelbuild/rules_rust/blob/ca4915c0210bcd240152a5333ecb24d266bda144/crate_universe/src/splicing/splicer.rs#L42 -+ rctx.delete(root.get_child(".git")) -+ - if strip_prefix: - dest_link = dest_dir.get_child(strip_prefix) - if not dest_link.exists: diff --git a/patches/rules_rs_windows_exec_linker.patch b/patches/rules_rs_windows_exec_linker.patch index 25892de836..f7110735d0 100644 --- a/patches/rules_rs_windows_exec_linker.patch +++ b/patches/rules_rs_windows_exec_linker.patch @@ -3,10 +3,9 @@ # Scope: Windows-only linker metadata for the generated `rules_rs` toolchains. diff --git a/rs/experimental/toolchains/declare_rustc_toolchains.bzl b/rs/experimental/toolchains/declare_rustc_toolchains.bzl -index 67e491c..3f1cff5 100644 --- a/rs/experimental/toolchains/declare_rustc_toolchains.bzl +++ b/rs/experimental/toolchains/declare_rustc_toolchains.bzl -@@ -50,6 +50,8 @@ def declare_rustc_toolchains( +@@ -58,6 +58,8 @@ def declare_rustc_toolchains( rust_toolchain( name = rust_toolchain_name, rust_doc = "{}rustdoc".format(rustc_repo_label), @@ -15,10 +14,10 @@ index 67e491c..3f1cff5 100644 rust_std = select(rust_std_select), rustc = "{}rustc".format(rustc_repo_label), cargo = "{}cargo".format(cargo_repo_label), -@@ -82,7 +84,20 @@ def declare_rustc_toolchains( - stdlib_linkflags = select({ - "@platforms//os:freebsd": ["-lexecinfo", "-lpthread"], - "@platforms//os:macos": ["-lSystem", "-lresolv"], +@@ -104,7 +106,20 @@ def declare_rustc_toolchains( + "@platforms//os:nixos": ["-ldl", "-lpthread"], + "@platforms//os:openbsd": ["-lpthread"], + "@platforms//os:ios": ["-lSystem", "-lobjc", "-Wl,-framework,Security", "-Wl,-framework,Foundation", "-lresolv"], - # TODO: windows + "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm": [ + "advapi32.lib", @@ -38,17 +37,12 @@ index 67e491c..3f1cff5 100644 }), default_edition = edition, diff --git a/rs/private/rustc_repository.bzl b/rs/private/rustc_repository.bzl -index f4f0286..6558bb2 100644 --- a/rs/private/rustc_repository.bzl +++ b/rs/private/rustc_repository.bzl -@@ -1,13 +1,28 @@ - load("@rules_rust//rust/platform:triple.bzl", "triple") - load( - "@rules_rust//rust/private:repository_utils.bzl", - "BUILD_for_compiler", +@@ -7,10 +7,24 @@ load( ) - load(":rust_repository_utils.bzl", "download_and_extract", "RUST_REPOSITORY_COMMON_ATTR") - + load(":rust_repository_utils.bzl", "RUST_REPOSITORY_COMMON_ATTR", "download_and_extract") ++ +_WINDOWS_EXEC_LINKER_BUILD = """ +filegroup( + name = "rust-lld", @@ -56,18 +50,17 @@ index f4f0286..6558bb2 100644 + visibility = ["//visibility:public"], +) +""" -+ + def _rustc_repository_impl(rctx): exec_triple = triple(rctx.attr.triple) download_and_extract(rctx, "rustc", "rustc", exec_triple) -- rctx.file("BUILD.bazel", BUILD_for_compiler(exec_triple)) -+ build_file = BUILD_for_compiler(exec_triple) + build_content = [BUILD_for_compiler(exec_triple)] + if exec_triple.system == "windows": + lld_link = rctx.which("lld-link.exe") + if lld_link == None: + fail("lld-link.exe not found on PATH") + rctx.symlink(lld_link, "bin/lld-link.exe") -+ build_file += _WINDOWS_EXEC_LINKER_BUILD -+ rctx.file("BUILD.bazel", build_file) - - return rctx.repo_metadata(reproducible = True) ++ build_content.append(_WINDOWS_EXEC_LINKER_BUILD) + if includes_rust_analyzer_proc_macro_srv(rctx.attr.version, rctx.attr.iso_date): + build_content.append(BUILD_for_rust_analyzer_proc_macro_srv(exec_triple)) + rctx.file("BUILD.bazel", "\n".join(build_content)) diff --git a/patches/rules_rs_windows_gnullvm_exec.patch b/patches/rules_rs_windows_gnullvm_exec.patch index 9a49523b78..f806113e56 100644 --- a/patches/rules_rs_windows_gnullvm_exec.patch +++ b/patches/rules_rs_windows_gnullvm_exec.patch @@ -2,7 +2,6 @@ # Scope: experimental platform/toolchain naming only; no Cargo target changes. diff --git a/rs/experimental/platforms/triples.bzl b/rs/experimental/platforms/triples.bzl -index 3ca3bb1..dd15656 100644 --- a/rs/experimental/platforms/triples.bzl +++ b/rs/experimental/platforms/triples.bzl @@ -30,7 +30,9 @@ SUPPORTED_EXEC_TRIPLES = [ @@ -16,22 +15,21 @@ index 3ca3bb1..dd15656 100644 "aarch64-apple-darwin", ] diff --git a/rs/experimental/toolchains/declare_rustc_toolchains.bzl b/rs/experimental/toolchains/declare_rustc_toolchains.bzl -index b9a0ce1..67e491c 100644 --- a/rs/experimental/toolchains/declare_rustc_toolchains.bzl +++ b/rs/experimental/toolchains/declare_rustc_toolchains.bzl -@@ -10,6 +10,11 @@ def _channel(version): +@@ -9,6 +9,11 @@ def _channel(version): + if version.startswith("beta"): return "beta" return "stable" - ++ +def _exec_triple_suffix(exec_triple): + if exec_triple.system == "windows": + return "{}_{}_{}".format(exec_triple.system, exec_triple.arch, exec_triple.abi) + return "{}_{}".format(exec_triple.system, exec_triple.arch) -+ - def declare_rustc_toolchains( - *, - version, -@@ -23,15 +28,14 @@ def declare_rustc_toolchains( + + def _rustc_flags_to_select(rustc_flags_by_triple): + return select( +@@ -31,15 +36,14 @@ def declare_rustc_toolchains( for triple in execs: exec_triple = _parse_triple(triple) @@ -50,7 +48,7 @@ index b9a0ce1..67e491c 100644 version_key, ) -@@ -90,11 +94,8 @@ def declare_rustc_toolchains( +@@ -116,11 +120,8 @@ def declare_rustc_toolchains( target_key = sanitize_triple(target_triple) native.toolchain( @@ -65,24 +63,22 @@ index b9a0ce1..67e491c 100644 target_settings = [ "@rules_rust//rust/toolchain/channel:" + channel, diff --git a/rs/experimental/toolchains/declare_rustfmt_toolchains.bzl b/rs/experimental/toolchains/declare_rustfmt_toolchains.bzl -index a219209..ecb6b05 100644 --- a/rs/experimental/toolchains/declare_rustfmt_toolchains.bzl +++ b/rs/experimental/toolchains/declare_rustfmt_toolchains.bzl -@@ -1,8 +1,13 @@ +@@ -1,7 +1,12 @@ load("@rules_rust//rust:toolchain.bzl", "rustfmt_toolchain") load("@rules_rust//rust/platform:triple.bzl", _parse_triple = "triple") -load("//rs/experimental/platforms:triples.bzl", "SUPPORTED_EXEC_TRIPLES") +load("//rs/experimental/platforms:triples.bzl", "SUPPORTED_EXEC_TRIPLES", "triple_to_constraint_set") load("//rs/experimental/toolchains:toolchain_utils.bzl", "sanitize_version") - ++ +def _exec_triple_suffix(exec_triple): + if exec_triple.system == "windows": + return "{}_{}_{}".format(exec_triple.system, exec_triple.arch, exec_triple.abi) + return "{}_{}".format(exec_triple.system, exec_triple.arch) -+ + def _channel(version): if version.startswith("nightly"): - return "nightly" @@ -22,14 +27,13 @@ def declare_rustfmt_toolchains( for triple in execs: @@ -111,15 +107,71 @@ index a219209..ecb6b05 100644 - "@platforms//cpu:" + exec_triple.arch, - ], + name = "{}_rustfmt_{}".format(triple_suffix, version_key), ++ exec_compatible_with = triple_to_constraint_set(triple), + target_compatible_with = [], + target_settings = [ + "@rules_rust//rust/toolchain/channel:" + channel, +diff --git a/rs/experimental/toolchains/declare_rust_analyzer_toolchains.bzl b/rs/experimental/toolchains/declare_rust_analyzer_toolchains.bzl +--- a/rs/experimental/toolchains/declare_rust_analyzer_toolchains.bzl ++++ b/rs/experimental/toolchains/declare_rust_analyzer_toolchains.bzl +@@ -4,7 +4,7 @@ load( + "@rules_rust//rust/private:repository_utils.bzl", + "includes_rust_analyzer_proc_macro_srv", + ) +-load("//rs/experimental/platforms:triples.bzl", "SUPPORTED_EXEC_TRIPLES") ++load("//rs/experimental/platforms:triples.bzl", "SUPPORTED_EXEC_TRIPLES", "triple_to_constraint_set") + load("//rs/experimental/toolchains:toolchain_utils.bzl", "sanitize_version") + + def _channel(version): +@@ -13,6 +13,11 @@ def _channel(version): + if version.startswith("beta"): + return "beta" + return "stable" ++ ++def _exec_triple_suffix(exec_triple): ++ if exec_triple.system == "windows": ++ return "{}_{}_{}".format(exec_triple.system, exec_triple.arch, exec_triple.abi) ++ return "{}_{}".format(exec_triple.system, exec_triple.arch) + + def _parse_version(version): + if "/" in version: +@@ -31,15 +36,14 @@ def declare_rust_analyzer_toolchains( + + for triple in execs: + exec_triple = _parse_triple(triple) +- triple_suffix = exec_triple.system + "_" + exec_triple.arch ++ triple_suffix = _exec_triple_suffix(exec_triple) + + rustc_repo_label = "@rustc_{}_{}//:".format(triple_suffix, rust_analyzer_version_key) + rust_analyzer_repo_label = "@rust_analyzer_{}_{}//:".format(triple_suffix, rust_analyzer_version_key) + rust_src_repo_label = "@rust_src_{}//lib/rustlib/src:rustc_srcs".format(rust_analyzer_version_key) + +- rust_analyzer_toolchain_name = "{}_{}_{}_rust_analyzer_toolchain".format( +- exec_triple.system, +- exec_triple.arch, ++ rust_analyzer_toolchain_name = "{}_{}_rust_analyzer_toolchain".format( ++ triple_suffix, + version_key, + ) + +@@ -57,11 +61,8 @@ def declare_rust_analyzer_toolchains( + rust_analyzer_toolchain(**rust_analyzer_toolchain_kwargs) + + native.toolchain( +- name = "{}_{}_rust_analyzer_{}".format(exec_triple.system, exec_triple.arch, version_key), +- exec_compatible_with = [ +- "@platforms//os:" + exec_triple.system, +- "@platforms//cpu:" + exec_triple.arch, +- ], ++ name = "{}_rust_analyzer_{}".format(triple_suffix, version_key), + exec_compatible_with = triple_to_constraint_set(triple), target_compatible_with = [], target_settings = [ "@rules_rust//rust/toolchain/channel:" + channel, diff --git a/rs/experimental/toolchains/module_extension.bzl b/rs/experimental/toolchains/module_extension.bzl -index 7bb0205..ace556b 100644 --- a/rs/experimental/toolchains/module_extension.bzl +++ b/rs/experimental/toolchains/module_extension.bzl -@@ -37,6 +37,11 @@ def _normalize_arch_name(arch): +@@ -39,6 +39,11 @@ def _normalize_arch_name(arch): return "aarch64" return arch @@ -131,7 +183,7 @@ index 7bb0205..ace556b 100644 def _sanitize_path_fragment(path): return path.replace("/", "_").replace(":", "_") -@@ -181,7 +186,7 @@ def _toolchains_impl(mctx): +@@ -209,7 +214,7 @@ def _toolchains_impl(mctx): for triple in SUPPORTED_EXEC_TRIPLES: exec_triple = _parse_triple(triple) @@ -140,7 +192,7 @@ index 7bb0205..ace556b 100644 rustc_name = "rustc_{}_{}".format(triple_suffix, version_key) rustc_repository( -@@ -230,7 +235,7 @@ def _toolchains_impl(mctx): +@@ -258,7 +263,7 @@ def _toolchains_impl(mctx): for triple in SUPPORTED_EXEC_TRIPLES: exec_triple = _parse_triple(triple) @@ -149,3 +201,12 @@ index 7bb0205..ace556b 100644 rustfmt_repository( name = "rustfmt_{}_{}".format(triple_suffix, version_key), +@@ -282,7 +287,7 @@ def _toolchains_impl(mctx): + + for triple in SUPPORTED_EXEC_TRIPLES: + exec_triple = _parse_triple(triple) +- triple_suffix = exec_triple.system + "_" + exec_triple.arch ++ triple_suffix = _exec_triple_suffix(exec_triple) + + rust_analyzer_repository( + name = "rust_analyzer_{}_{}".format(triple_suffix, version_key), diff --git a/patches/rules_rust_repository_set_exec_constraints.patch b/patches/rules_rust_repository_set_exec_constraints.patch deleted file mode 100644 index 31afae4f7a..0000000000 --- a/patches/rules_rust_repository_set_exec_constraints.patch +++ /dev/null @@ -1,26 +0,0 @@ -# What: let `rules_rust` repository_set entries specify an explicit exec-platform -# constraint set. -# Why: codex needs Windows nightly lint toolchains to run helper binaries on an -# MSVC exec platform while still targeting `windows-gnullvm` crates. - -diff --git a/rust/extensions.bzl b/rust/extensions.bzl ---- a/rust/extensions.bzl -+++ b/rust/extensions.bzl -@@ -52,6 +52,7 @@ def _rust_impl(module_ctx): - "allocator_library": repository_set.allocator_library, - "dev_components": repository_set.dev_components, - "edition": repository_set.edition, -+ "exec_compatible_with": [str(v) for v in repository_set.exec_compatible_with] if repository_set.exec_compatible_with else None, - "exec_triple": repository_set.exec_triple, - "extra_target_triples": {repository_set.target_triple: [str(v) for v in repository_set.target_compatible_with]}, - "name": repository_set.name, -@@ -166,6 +167,9 @@ _COMMON_TAG_KWARGS = { - - _RUST_REPOSITORY_SET_TAG_ATTRS = { -+ "exec_compatible_with": attr.label_list( -+ doc = "Execution platform constraints for this repository_set.", -+ ), - "exec_triple": attr.string( - doc = "Exec triple for this repository_set.", - ), - "name": attr.string( diff --git a/patches/rules_rust_windows_exec_std.patch b/patches/rules_rust_windows_exec_std.patch index aa20ee0e51..e8e4c4e55c 100644 --- a/patches/rules_rust_windows_exec_std.patch +++ b/patches/rules_rust_windows_exec_std.patch @@ -7,7 +7,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl --- a/rust/toolchain.bzl +++ b/rust/toolchain.bzl -@@ -209,6 +209,7 @@ def _generate_sysroot( +@@ -211,6 +211,7 @@ clippy = None, cargo_clippy = None, llvm_tools = None, @@ -15,23 +15,22 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl rust_std = None, rustfmt = None, linker = None): -@@ -312,7 +313,15 @@ def _generate_sysroot( - +@@ -315,6 +316,14 @@ # Made available to support $(location) expansion in stdlib_linkflags and extra_rustc_flags. transitive_file_sets.append(depset(ctx.files.rust_std)) -+ + + sysroot_exec_rust_std = None + if exec_rust_std: + sysroot_exec_rust_std = _symlink_sysroot_tree(ctx, name, exec_rust_std) + transitive_file_sets.extend([sysroot_exec_rust_std]) - ++ + # Made available to support $(location) expansion in extra_exec_rustc_flags. + transitive_file_sets.append(depset(ctx.files.exec_rust_std)) + # Declare a file in the root of the sysroot to make locating the sysroot easy sysroot_anchor = ctx.actions.declare_file("{}/rust.sysroot".format(name)) ctx.actions.write( -@@ -323,6 +332,7 @@ def _generate_sysroot( +@@ -325,6 +334,7 @@ "cargo-clippy: {}".format(cargo_clippy), "linker: {}".format(linker), "llvm_tools: {}".format(llvm_tools), @@ -39,7 +38,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl "rust_std: {}".format(rust_std), "rustc_lib: {}".format(rustc_lib), "rustc: {}".format(rustc), -@@ -340,6 +350,7 @@ def _generate_sysroot( +@@ -342,6 +352,7 @@ cargo_clippy = sysroot_cargo_clippy, clippy = sysroot_clippy, linker = sysroot_linker, @@ -47,7 +46,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl rust_std = sysroot_rust_std, rustc = sysroot_rustc, rustc_lib = sysroot_rustc_lib, -@@ -410,12 +421,14 @@ def _rust_toolchain_impl(ctx): +@@ -412,12 +423,14 @@ ) rust_std = ctx.attr.rust_std @@ -62,7 +61,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl rust_std = rust_std, rustfmt = ctx.file.rustfmt, clippy = ctx.file.clippy_driver, -@@ -452,7 +465,7 @@ def _rust_toolchain_impl(ctx): +@@ -454,7 +467,7 @@ expanded_stdlib_linkflags = _expand_flags(ctx, "stdlib_linkflags", rust_std[rust_common.stdlib_info].srcs, make_variables) expanded_extra_rustc_flags = _expand_flags(ctx, "extra_rustc_flags", rust_std[rust_common.stdlib_info].srcs, make_variables) @@ -71,7 +70,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl linking_context = cc_common.create_linking_context( linker_inputs = depset([ -@@ -793,6 +806,10 @@ rust_toolchain = rule( +@@ -807,6 +820,10 @@ doc = "The Rust standard library.", mandatory = True, ), @@ -85,7 +84,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl diff --git a/rust/private/repository_utils.bzl b/rust/private/repository_utils.bzl --- a/rust/private/repository_utils.bzl +++ b/rust/private/repository_utils.bzl -@@ -341,6 +341,7 @@ rust_toolchain( +@@ -342,6 +342,7 @@ name = "{toolchain_name}", rust_doc = "//:rustdoc", rust_std = "//:rust_std-{target_triple}", @@ -93,15 +92,15 @@ diff --git a/rust/private/repository_utils.bzl b/rust/private/repository_utils.b rustc = "//:rustc", linker = {linker_label}, linker_type = {linker_type}, -@@ -384,6 +385,7 @@ def BUILD_for_rust_toolchain( - include_llvm_tools, - include_linker, +@@ -389,6 +390,7 @@ + include_llvm_tools = False, + include_linker = False, include_objcopy = False, + exec_rust_std_label = None, stdlib_linkflags = None, extra_rustc_flags = None, extra_exec_rustc_flags = None, -@@ -405,6 +407,7 @@ def BUILD_for_rust_toolchain( +@@ -412,6 +414,7 @@ include_llvm_tools (bool): Whether llvm-tools are present in the toolchain. include_linker (bool): Whether a linker is available in the toolchain. include_objcopy (bool): Whether rust-objcopy is available in the toolchain. @@ -109,7 +108,7 @@ diff --git a/rust/private/repository_utils.bzl b/rust/private/repository_utils.b stdlib_linkflags (list, optional): Overridden flags needed for linking to rust stdlib, akin to BAZEL_LINKLIBS. Defaults to None. -@@ -453,6 +456,7 @@ def BUILD_for_rust_toolchain( +@@ -465,6 +468,7 @@ staticlib_ext = system_to_staticlib_ext(target_triple.system), dylib_ext = system_to_dylib_ext(target_triple.system), allocator_library = repr(allocator_library_label), @@ -120,7 +119,7 @@ diff --git a/rust/private/repository_utils.bzl b/rust/private/repository_utils.b diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl --- a/rust/private/rustc.bzl +++ b/rust/private/rustc.bzl -@@ -1011,7 +1011,10 @@ def construct_arguments( +@@ -1010,7 +1010,10 @@ if build_metadata and not use_json_output: fail("build_metadata requires parse_json_output") @@ -135,7 +134,7 @@ diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl diff --git a/rust/repositories.bzl b/rust/repositories.bzl --- a/rust/repositories.bzl +++ b/rust/repositories.bzl -@@ -536,6 +536,18 @@ def _rust_toolchain_tools_repository_impl(ctx): +@@ -574,6 +574,18 @@ build_components.append(rust_stdlib_content) sha256s.update(rust_stdlib_sha256) @@ -154,7 +153,7 @@ diff --git a/rust/repositories.bzl b/rust/repositories.bzl stdlib_linkflags = None if "BAZEL_RUST_STDLIB_LINKFLAGS" in ctx.os.environ: stdlib_linkflags = ctx.os.environ["BAZEL_RUST_STDLIB_LINKFLAGS"].split(":") -@@ -552,6 +564,7 @@ def _rust_toolchain_tools_repository_impl(ctx): +@@ -590,6 +602,7 @@ include_llvm_tools = include_llvm_tools, include_linker = include_linker, include_objcopy = include_objcopy, @@ -162,12 +161,7 @@ diff --git a/rust/repositories.bzl b/rust/repositories.bzl extra_rustc_flags = ctx.attr.extra_rustc_flags, extra_exec_rustc_flags = ctx.attr.extra_exec_rustc_flags, opt_level = ctx.attr.opt_level if ctx.attr.opt_level else None, -@@ -575,8 +588,16 @@ def _rust_toolchain_tools_repository_impl(ctx): - if ctx.attr.dev_components: - rustc_dev_sha256 = load_rustc_dev_nightly( - ctx = ctx, - target_triple = target_triple, - version = version, +@@ -608,6 +621,14 @@ iso_date = iso_date, ) sha256s.update(rustc_dev_sha256) @@ -179,3 +173,6 @@ diff --git a/rust/repositories.bzl b/rust/repositories.bzl + iso_date = iso_date, + ) + sha256s.update(exec_rustc_dev_sha256) + + ctx.file("WORKSPACE.bazel", """workspace(name = "{}")""".format( + ctx.name, diff --git a/patches/rules_rust_windows_msvc_direct_link_args.patch b/patches/rules_rust_windows_msvc_direct_link_args.patch index 05aef785df..fcf8a487be 100644 --- a/patches/rules_rust_windows_msvc_direct_link_args.patch +++ b/patches/rules_rust_windows_msvc_direct_link_args.patch @@ -26,47 +26,44 @@ filtered_args.append(processed_arg) if processed_arg == "--sysroot" and i + 1 < len(link_args): # Two-part argument, keep the next arg too -@@ -2305,7 +2335,7 @@ - return crate.metadata.dirname - return crate.output.dirname +@@ -2256,8 +2256,10 @@ + use_pic, + ambiguous_libs, + get_lib_name, ++ for_windows = False, + for_darwin = False, +- flavor_msvc = False): ++ flavor_msvc = False, ++ use_direct_driver = False): + """_summary_ --def _portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = False, for_darwin = False, flavor_msvc = False): -+def _portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = False, for_darwin = False, flavor_msvc = False, use_direct_driver = False): - artifact = get_preferred_artifact(lib, use_pic) - if ambiguous_libs and artifact.path in ambiguous_libs: - artifact = ambiguous_libs[artifact.path] -@@ -2344,6 +2344,11 @@ - artifact.basename.startswith("test-") or artifact.basename.startswith("std-") + Args: +@@ -2310,6 +2312,11 @@ ): return [] if for_darwin else ["-lstatic=%s" % get_lib_name(artifact)] -+ + + if for_windows and use_direct_driver and not artifact.basename.endswith(".lib"): + return [ + "-Clink-arg={}".format(artifact.path), + ] - ++ if flavor_msvc: return [ -@@ -2381,7 +2386,7 @@ + "-lstatic=%s" % get_lib_name(artifact), +@@ -2346,7 +2353,7 @@ ]) elif include_link_flags: get_lib_name = get_lib_name_for_windows if flavor_msvc else get_lib_name_default -- ret.extend(_portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc)) -+ ret.extend(_portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc, use_direct_driver = use_direct_driver)) +- ret.extend(portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc)) ++ ret.extend(portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = True, flavor_msvc = flavor_msvc, use_direct_driver = use_direct_driver)) # Windows toolchains can inherit POSIX defaults like -pthread from C deps, # which fails to link with the MinGW/LLD toolchain. Drop them here. -@@ -2453,14 +2483,21 @@ +@@ -2522,17 +2529,25 @@ else: # For all other crate types we want to link C++ runtime library statically # (for example libstdc++.a or libc++.a). + runtime_libs = cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration) -+ if toolchain.target_os == "windows" and use_direct_link_driver: -+ runtime_libs = depset([ -+ runtime_lib -+ for runtime_lib in runtime_libs.to_list() -+ if runtime_lib.basename.endswith(".lib") -+ ]) args.add_all( - cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration), + runtime_libs, @@ -79,11 +76,18 @@ - map_each = get_lib_name, - format_each = "-lstatic=%s", - ) -+ args.add_all( -+ runtime_libs, -+ map_each = get_lib_name, -+ format_each = "-lstatic=%s", -+ ) ++ if toolchain.target_os == "windows" and use_direct_link_driver: ++ for runtime_lib in runtime_libs.to_list(): ++ if runtime_lib.basename.endswith(".lib"): ++ args.add(get_lib_name(runtime_lib), format = "-lstatic=%s") ++ else: ++ args.add(runtime_lib.path, format = "--codegen=link-arg=%s") ++ else: ++ args.add_all( ++ runtime_libs, ++ map_each = get_lib_name, ++ format_each = "-lstatic=%s", ++ ) def _get_dirname(file): """A helper function for `_add_native_link_flags`. From 96d35dd640450c9ce3d739a31f489ba985698d0e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 17 Apr 2026 16:14:11 -0700 Subject: [PATCH 03/18] bazel: use native rust test sharding (#18082) ## Why The large Rust test suites are slow and include some of our flakiest tests, so we want to run them with Bazel native sharding while keeping shard membership stable between runs. This is the simpler follow-up to the explicit-label experiment in #17998. Since #18397 upgraded Codex to `rules_rs` `0.0.58`, which includes the stable test-name hashing support from hermeticbuild/rules_rust#14, this PR only needs to wire Codex's Bazel macros into that support. Using native sharding preserves BuildBuddy's sharded-test UI and Bazel's per-shard test action caching. Using stable name hashing avoids reshuffling every test when one test is added or removed. ## What Changed `codex_rust_crate` now accepts `test_shard_counts` and applies the right Bazel/rules_rust attributes to generated unit and integration test rules. Matched tests are also marked `flaky = True`, giving them Bazel's default three attempts. This PR shards these labels 8 ways: ```text //codex-rs/core:core-all-test //codex-rs/core:core-unit-tests //codex-rs/app-server:app-server-all-test //codex-rs/app-server:app-server-unit-tests //codex-rs/tui:tui-unit-tests ``` ## Verification `bazel query --output=build` over the selected public labels and their inner unit-test binaries confirmed the expected `shard_count = 8`, `flaky = True`, and `experimental_enable_sharding = True` attributes. Also verified that we see the shards as expected in BuildBuddy so they can be analyzed independently. Co-authored-by: Codex --- codex-rs/app-server/BUILD.bazel | 4 ++++ codex-rs/core/BUILD.bazel | 4 ++++ codex-rs/tui/BUILD.bazel | 3 +++ defs.bzl | 38 +++++++++++++++++++++++++++++++-- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index 872f533be1..d2d3f42a19 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -4,5 +4,9 @@ codex_rust_crate( name = "app-server", crate_name = "codex_app_server", integration_test_timeout = "long", + test_shard_counts = { + "app-server-all-test": 8, + "app-server-unit-tests": 8, + }, test_tags = ["no-sandbox"], ) diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 434dc1f6a4..dd52bce43d 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -47,6 +47,10 @@ codex_rust_crate( # succeeds without this workaround. "//:AGENTS.md", ], + test_shard_counts = { + "core-all-test": 8, + "core-unit-tests": 8, + }, test_tags = ["no-sandbox"], unit_test_timeout = "long", extra_binaries = [ diff --git a/codex-rs/tui/BUILD.bazel b/codex-rs/tui/BUILD.bazel index 33cc133431..d0aab0805a 100644 --- a/codex-rs/tui/BUILD.bazel +++ b/codex-rs/tui/BUILD.bazel @@ -24,4 +24,7 @@ codex_rust_crate( "//codex-rs/cli:codex", ], rustc_flags_extra = MACOS_WEBRTC_RUSTC_LINK_FLAGS, + test_shard_counts = { + "tui-unit-tests": 8, + }, ) diff --git a/defs.bzl b/defs.bzl index 6972b65a65..53114a5773 100644 --- a/defs.bzl +++ b/defs.bzl @@ -140,6 +140,7 @@ def codex_rust_crate( integration_test_args = [], integration_test_timeout = None, test_data_extra = [], + test_shard_counts = {}, test_tags = [], unit_test_timeout = None, extra_binaries = []): @@ -174,6 +175,11 @@ def codex_rust_crate( integration_test_timeout: Optional Bazel timeout for integration test targets generated from `tests/*.rs`. test_data_extra: Extra runtime data for tests. + test_shard_counts: Mapping from generated test target name to Bazel + shard count. Matching tests use native Bazel sharding on the + original test label, while rules_rust assigns each Rust test case + to a stable bucket by hashing the test name. Matching tests are + also marked flaky, which gives them Bazel's default three attempts. test_tags: Tags applied to unit + integration test targets. Typically used to disable the sandbox, but see https://bazel.build/reference/be/common-definitions#common.tags unit_test_timeout: Optional Bazel timeout for the unit-test target @@ -246,7 +252,13 @@ def codex_rust_crate( visibility = ["//visibility:public"], ) + unit_test_name = name + "-unit-tests" unit_test_binary = name + "-unit-tests-bin" + unit_test_shard_count = _test_shard_count(test_shard_counts, unit_test_name) + unit_test_binary_kwargs = {} + if unit_test_shard_count: + unit_test_binary_kwargs["experimental_enable_sharding"] = True + rust_test( name = unit_test_binary, crate = name, @@ -265,14 +277,18 @@ def codex_rust_crate( rustc_env = rustc_env, data = test_data_extra, tags = test_tags + ["manual"], + **unit_test_binary_kwargs ) unit_test_kwargs = {} if unit_test_timeout: unit_test_kwargs["timeout"] = unit_test_timeout + if unit_test_shard_count: + unit_test_kwargs["shard_count"] = unit_test_shard_count + unit_test_kwargs["flaky"] = True workspace_root_test( - name = name + "-unit-tests", + name = unit_test_name, env = test_env, test_bin = ":" + unit_test_binary, workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker", @@ -318,6 +334,14 @@ def codex_rust_crate( if not test_name.endswith("-test"): test_name += "-test" + test_kwargs = {} + test_kwargs.update(integration_test_kwargs) + test_shard_count = _test_shard_count(test_shard_counts, test_name) + if test_shard_count: + test_kwargs["experimental_enable_sharding"] = True + test_kwargs["shard_count"] = test_shard_count + test_kwargs["flaky"] = True + rust_test( name = test_name, crate_name = test_crate_name, @@ -339,5 +363,15 @@ def codex_rust_crate( # execute from the repo root and can misplace integration snapshots. env = cargo_env, tags = test_tags, - **integration_test_kwargs + **test_kwargs ) + +def _test_shard_count(test_shard_counts, test_name): + shard_count = test_shard_counts.get(test_name) + if shard_count == None: + return None + + if shard_count < 1: + fail("test_shard_counts[{}] must be a positive integer".format(test_name)) + + return shard_count From 120bbf46c1f659a6009ef80e7426c6b7cb9062af Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 17 Apr 2026 16:31:03 -0700 Subject: [PATCH 04/18] Update image resizing to fit 2048 square bounds (#18384) We don't have to downsize to 768 height. --- codex-rs/core/tests/suite/view_image.rs | 61 +++++++++--------- codex-rs/utils/image/src/lib.rs | 82 +++++++++++++++---------- 2 files changed, 79 insertions(+), 64 deletions(-) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f9d6d9402c..47254ab177 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_exec_server::CreateDirectoryOptions; @@ -127,10 +128,10 @@ async fn write_workspace_png( write_workspace_file(test, rel_path, png_bytes(width, height, rgba)?).await } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - +async fn assert_user_turn_local_image_resizes_to( + original_dimensions: (u32, u32), + expected_dimensions: (u32, u32), +) -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex(); @@ -142,8 +143,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { .. } = &test; - let original_width = 2304; - let original_height = 864; + let (original_width, original_height) = original_dimensions; let local_image_dir = tempfile::tempdir()?; let abs_path = local_image_dir.path().join("example.png"); let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([20u8, 40, 60, 255])); @@ -187,7 +187,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { let body = mock.single_request().body_json(); let image_message = - find_image_message(&body).expect("pending input image message not included in request"); + find_image_message(&body).context("pending input image message not included in request")?; let image_url = image_message .get("content") .and_then(Value::as_array) @@ -200,26 +200,37 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { } }) }) - .expect("image_url present"); + .context("image_url present")?; let (prefix, encoded) = image_url .split_once(',') - .expect("image url contains data prefix"); + .context("image url contains data prefix")?; assert_eq!(prefix, "data:image/png;base64"); let decoded = BASE64_STANDARD .decode(encoded) - .expect("image data decodes from base64 for request"); - let resized = load_from_memory(&decoded).expect("load resized image"); + .context("image data decodes from base64 for request")?; + let resized = load_from_memory(&decoded).context("load resized image")?; let (width, height) = resized.dimensions(); - assert!(width <= 2048); - assert!(height <= 768); - assert!(width < original_width); - assert!(height < original_height); + assert_eq!((width, height), expected_dimensions); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768)).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_with_vertical_local_image_resizes_to_square_bounds() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + assert_user_turn_local_image_resizes_to((1024, 4096), (512, 2048)).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -347,10 +358,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { .expect("image data decodes from base64 for request"); let resized = load_from_memory(&decoded).expect("load resized image"); let (resized_width, resized_height) = resized.dimensions(); - assert!(resized_width <= 2048); - assert!(resized_height <= 768); - assert!(resized_width < original_width); - assert!(resized_height < original_height); + assert_eq!((resized_width, resized_height), (2048, 768)); Ok(()) } @@ -637,10 +645,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { .expect("image data decodes from base64 for request"); let resized = load_from_memory(&decoded).expect("load resized image"); let (width, height) = resized.dimensions(); - assert!(width <= 2048); - assert!(height <= 768); - assert!(width < original_width); - assert!(height < original_height); + assert_eq!((width, height), (2048, 768)); Ok(()) } @@ -740,10 +745,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a .expect("image data decodes from base64 for request"); let resized = load_from_memory(&decoded).expect("load resized image"); let (resized_width, resized_height) = resized.dimensions(); - assert!(resized_width <= 2048); - assert!(resized_height <= 768); - assert!(resized_width < original_width); - assert!(resized_height < original_height); + assert_eq!((resized_width, resized_height), (2048, 768)); Ok(()) } @@ -841,10 +843,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only .expect("image data decodes from base64 for request"); let resized = load_from_memory(&decoded).expect("load resized image"); let (resized_width, resized_height) = resized.dimensions(); - assert!(resized_width <= 2048); - assert!(resized_height <= 768); - assert!(resized_width < original_width); - assert!(resized_height < original_height); + assert_eq!((resized_width, resized_height), (2048, 768)); Ok(()) } diff --git a/codex-rs/utils/image/src/lib.rs b/codex-rs/utils/image/src/lib.rs index 0c0a026e41..a3f595a183 100644 --- a/codex-rs/utils/image/src/lib.rs +++ b/codex-rs/utils/image/src/lib.rs @@ -15,10 +15,8 @@ use image::codecs::jpeg::JpegEncoder; use image::codecs::png::PngEncoder; use image::codecs::webp::WebPEncoder; use image::imageops::FilterType; -/// Maximum width used when resizing images before uploading. -pub const MAX_WIDTH: u32 = 2048; -/// Maximum height used when resizing images before uploading. -pub const MAX_HEIGHT: u32 = 768; +/// Maximum width or height used when resizing images before uploading. +pub const MAX_DIMENSION: u32 = 2048; pub mod error; @@ -80,40 +78,41 @@ pub fn load_for_prompt_bytes( let (width, height) = dynamic.dimensions(); - let encoded = - if mode == PromptImageMode::Original || (width <= MAX_WIDTH && height <= MAX_HEIGHT) { - if let Some(format) = format.filter(|format| can_preserve_source_bytes(*format)) { - let mime = format_to_mime(format); - EncodedImage { - bytes: file_bytes, - mime, - width, - height, - } - } else { - let (bytes, output_format) = encode_image(&dynamic, ImageFormat::Png)?; - let mime = format_to_mime(output_format); - EncodedImage { - bytes, - mime, - width, - height, - } + let encoded = if mode == PromptImageMode::Original + || (width <= MAX_DIMENSION && height <= MAX_DIMENSION) + { + if let Some(format) = format.filter(|format| can_preserve_source_bytes(*format)) { + let mime = format_to_mime(format); + EncodedImage { + bytes: file_bytes, + mime, + width, + height, } } else { - let resized = dynamic.resize(MAX_WIDTH, MAX_HEIGHT, FilterType::Triangle); - let target_format = format - .filter(|format| can_preserve_source_bytes(*format)) - .unwrap_or(ImageFormat::Png); - let (bytes, output_format) = encode_image(&resized, target_format)?; + let (bytes, output_format) = encode_image(&dynamic, ImageFormat::Png)?; let mime = format_to_mime(output_format); EncodedImage { bytes, mime, - width: resized.width(), - height: resized.height(), + width, + height, } - }; + } + } else { + let resized = dynamic.resize(MAX_DIMENSION, MAX_DIMENSION, FilterType::Triangle); + let target_format = format + .filter(|format| can_preserve_source_bytes(*format)) + .unwrap_or(ImageFormat::Png); + let (bytes, output_format) = encode_image(&resized, target_format)?; + let mime = format_to_mime(output_format); + EncodedImage { + bytes, + mime, + width: resized.width(), + height: resized.height(), + } + }; Ok(encoded) }) @@ -251,8 +250,8 @@ mod tests { ) .expect("process image"); - assert!(processed.width <= MAX_WIDTH); - assert!(processed.height <= MAX_HEIGHT); + assert!(processed.width <= MAX_DIMENSION); + assert!(processed.height <= MAX_DIMENSION); assert_eq!(processed.mime, mime); let detected_format = @@ -265,6 +264,23 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread")] + async fn downscales_tall_image_to_fit_square_bounds() { + let image = ImageBuffer::from_pixel(1024, 4096, Rgba([200u8, 10, 10, 255])); + let original_bytes = image_bytes(&image, ImageFormat::Png); + + let processed = load_for_prompt_bytes( + Path::new("in-memory-image"), + original_bytes, + PromptImageMode::ResizeToFit, + ) + .expect("process image"); + + assert_eq!(processed.width, 512); + assert_eq!(processed.height, MAX_DIMENSION); + assert_eq!(processed.mime, "image/png"); + } + #[tokio::test(flavor = "multi_thread")] async fn preserves_large_image_in_original_mode() { let image = ImageBuffer::from_pixel(4096, 2048, Rgba([180u8, 30, 30, 255])); From 26d9894a27049059c88c8d93a66a6427002c6508 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 17 Apr 2026 16:47:58 -0700 Subject: [PATCH 05/18] feat: Add remote plugin fields to plugin API (#17277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Update the plugin API for the new remote plugin model. The mental model is no longer “keep local plugin state in sync with remote.” Instead, local and remote plugins are becoming separate sources. Remote catalog entries can be shown directly from the remote API before installation; after installation they are still downloaded into the local cache for execution, but remote installed state will come from the API and be held in memory rather than being read from config. • ## API changes - Remove `forceRemoteSync` from `plugin/list`, `plugin/install`, and `plugin/uninstall`. - Remove `remoteSyncError` from `plugin/list`. - Add remote-capable metadata to `plugin/list` / `plugin/read`: - nullable `marketplaces[].path` - `source: { type: "remote", downloadUrl }` - URL asset fields alongside local path fields: `composerIconUrl`, `logoUrl`, `screenshotUrls` - Make `plugin/read` and `plugin/install` source-compatible: - `marketplacePath?: AbsolutePathBuf | null` - `remoteMarketplaceName?: string | null` - exactly one source is required at runtime --- .../schema/json/ClientRequest.json | 44 +-- .../codex_app_server_protocol.schemas.json | 103 +++++-- .../codex_app_server_protocol.v2.schemas.json | 103 +++++-- .../schema/json/v2/PluginInstallParams.json | 20 +- .../schema/json/v2/PluginListParams.json | 4 - .../schema/json/v2/PluginListResponse.json | 59 +++- .../schema/json/v2/PluginReadParams.json | 16 +- .../schema/json/v2/PluginReadResponse.json | 42 +++ .../schema/json/v2/PluginUninstallParams.json | 4 - .../typescript/v2/PluginInstallParams.ts | 6 +- .../schema/typescript/v2/PluginInterface.ts | 26 +- .../schema/typescript/v2/PluginListParams.ts | 7 +- .../typescript/v2/PluginListResponse.ts | 2 +- .../typescript/v2/PluginMarketplaceEntry.ts | 7 +- .../schema/typescript/v2/PluginReadParams.ts | 2 +- .../schema/typescript/v2/PluginSource.ts | 2 +- .../typescript/v2/PluginUninstallParams.ts | 6 +- .../app-server-protocol/src/protocol/v2.rs | 267 +++++++++++++++--- codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 154 +++++----- .../tests/suite/v2/external_agent_config.rs | 5 +- .../tests/suite/v2/plugin_install.rs | 214 +++++++------- .../app-server/tests/suite/v2/plugin_list.rs | 206 ++------------ .../app-server/tests/suite/v2/plugin_read.rs | 111 +++++++- .../tests/suite/v2/plugin_uninstall.rs | 76 ----- codex-rs/core/src/plugins/manager.rs | 10 +- codex-rs/tui/src/app.rs | 20 +- codex-rs/tui/src/chatwidget/plugins.rs | 50 ++-- ...ts__plugins_popup_curated_marketplace.snap | 3 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 8 +- .../chatwidget/tests/popups_and_settings.rs | 12 +- 31 files changed, 919 insertions(+), 672 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ac9f480a8c..d195d57b6c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1437,19 +1437,27 @@ }, "PluginInstallParams": { "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "type": "object" @@ -1465,10 +1473,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "type": "object" @@ -1476,24 +1480,32 @@ "PluginReadParams": { "properties": { "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "type": "object" }, "PluginUninstallParams": { "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "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 fb393c1145..9fb9c3e3e6 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 @@ -10208,19 +10208,27 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginInstallParams", @@ -10282,6 +10290,14 @@ { "type": "null" } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "defaultPrompt": { @@ -10314,6 +10330,14 @@ { "type": "null" } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "longDescription": { @@ -10328,7 +10352,15 @@ "null" ] }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", "items": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -10355,6 +10387,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -10371,10 +10404,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "title": "PluginListParams", @@ -10402,12 +10431,6 @@ "$ref": "#/definitions/v2/PluginMarketplaceEntry" }, "type": "array" - }, - "remoteSyncError": { - "type": [ - "string", - "null" - ] } }, "required": [ @@ -10432,7 +10455,15 @@ "type": "string" }, "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, "plugins": { "items": { @@ -10443,7 +10474,6 @@ }, "required": [ "name", - "path", "plugins" ], "type": "object" @@ -10452,14 +10482,26 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "marketplacePath": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginReadParams", @@ -10537,6 +10579,23 @@ ], "title": "GitPluginSource", "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" } ] }, @@ -10588,10 +10647,6 @@ "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "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 458367e205..da0381d8b1 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 @@ -6960,19 +6960,27 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginInstallParams", @@ -7034,6 +7042,14 @@ { "type": "null" } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "defaultPrompt": { @@ -7066,6 +7082,14 @@ { "type": "null" } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "longDescription": { @@ -7080,7 +7104,15 @@ "null" ] }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -7107,6 +7139,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -7123,10 +7156,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "title": "PluginListParams", @@ -7154,12 +7183,6 @@ "$ref": "#/definitions/PluginMarketplaceEntry" }, "type": "array" - }, - "remoteSyncError": { - "type": [ - "string", - "null" - ] } }, "required": [ @@ -7184,7 +7207,15 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, "plugins": { "items": { @@ -7195,7 +7226,6 @@ }, "required": [ "name", - "path", "plugins" ], "type": "object" @@ -7204,14 +7234,26 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginReadParams", @@ -7289,6 +7331,23 @@ ], "title": "GitPluginSource", "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" } ] }, @@ -7340,10 +7399,6 @@ "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json index 6890705311..ad3c0c1079 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -7,19 +7,27 @@ } }, "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginInstallParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 669ff92b9e..27ea8c4df3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -16,10 +16,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "title": "PluginListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index ee039060e6..72c941c45f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -74,6 +74,14 @@ { "type": "null" } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "defaultPrompt": { @@ -106,6 +114,14 @@ { "type": "null" } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "longDescription": { @@ -120,7 +136,15 @@ "null" ] }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -147,6 +171,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -167,7 +192,15 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, "plugins": { "items": { @@ -178,7 +211,6 @@ }, "required": [ "name", - "path", "plugins" ], "type": "object" @@ -242,6 +274,23 @@ ], "title": "GitPluginSource", "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" } ] }, @@ -311,12 +360,6 @@ "$ref": "#/definitions/PluginMarketplaceEntry" }, "type": "array" - }, - "remoteSyncError": { - "type": [ - "string", - "null" - ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json index a720ae3b59..5cc3e5cab5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json @@ -8,14 +8,26 @@ }, "properties": { "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginReadParams", 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 43628e28f0..5ec07f00f1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -126,6 +126,14 @@ { "type": "null" } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "defaultPrompt": { @@ -158,6 +166,14 @@ { "type": "null" } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" ] }, "longDescription": { @@ -172,7 +188,15 @@ "null" ] }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -199,6 +223,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -262,6 +287,23 @@ ], "title": "GitPluginSource", "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json index a6d7ec78bb..5b7e0a592f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts index 9ac1c50c1e..257dc47a1e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts @@ -3,8 +3,4 @@ // 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"; -export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, -/** - * When true, apply the remote plugin change before the local install flow. - */ -forceRemoteSync?: boolean, }; +export type PluginInstallParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts index 7e0a48aaec..4e97ee66f3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -8,4 +8,28 @@ export type PluginInterface = { displayName: string | null, shortDescription: st * Starter prompts for the plugin. Capped at 3 entries with a maximum of * 128 characters per entry. */ -defaultPrompt: Array | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array, }; +defaultPrompt: Array | null, brandColor: string | null, +/** + * Local composer icon path, resolved from the installed plugin package. + */ +composerIcon: AbsolutePathBuf | null, +/** + * Remote composer icon URL from the plugin catalog. + */ +composerIconUrl: string | null, +/** + * Local logo path, resolved from the installed plugin package. + */ +logo: AbsolutePathBuf | null, +/** + * Remote logo URL from the plugin catalog. + */ +logoUrl: string | null, +/** + * Local screenshot paths, resolved from the installed plugin package. + */ +screenshots: Array, +/** + * Remote screenshot URLs from the plugin catalog. + */ +screenshotUrls: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index cd2a0cde1e..dcf23796db 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -8,9 +8,4 @@ export type PluginListParams = { * Optional working directories used to discover repo marketplaces. When omitted, * only home-scoped marketplaces and the official curated marketplace are considered. */ -cwds?: Array | null, -/** - * When true, reconcile the official curated marketplace against the remote plugin state - * before listing marketplaces. - */ -forceRemoteSync?: boolean, }; +cwds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts index 7ae5f8e505..d50200c905 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -4,4 +4,4 @@ import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; -export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, remoteSyncError: string | null, featuredPluginIds: Array, }; +export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, featuredPluginIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts index c0ab75b8f9..f9dcee27df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts @@ -5,4 +5,9 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { MarketplaceInterface } from "./MarketplaceInterface"; import type { PluginSummary } from "./PluginSummary"; -export type PluginMarketplaceEntry = { name: string, path: AbsolutePathBuf, interface: MarketplaceInterface | null, plugins: Array, }; +export type PluginMarketplaceEntry = { name: string, +/** + * Local marketplace file path when the marketplace is backed by a local file. + * Remote-only catalog marketplaces do not have a local path. + */ +path: AbsolutePathBuf | null, interface: MarketplaceInterface | null, plugins: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts index cd6696873d..8c4394f0da 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.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 { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginReadParams = { marketplacePath: AbsolutePathBuf, pluginName: string, }; +export type PluginReadParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts index 5c8771aa0b..f6e867195d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.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 { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, }; +export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts index aa1d1bfef9..e7f52c0eb3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts @@ -2,8 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginUninstallParams = { pluginId: string, -/** - * When true, apply the remote plugin change before the local uninstall flow. - */ -forceRemoteSync?: boolean, }; +export type PluginUninstallParams = { pluginId: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1edd7d2dde..340a80a618 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3459,10 +3459,6 @@ pub struct PluginListParams { /// only home-scoped marketplaces and the official curated marketplace are considered. #[ts(optional = nullable)] pub cwds: Option>, - /// When true, reconcile the official curated marketplace against the remote plugin state - /// before listing marketplaces. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3472,7 +3468,6 @@ pub struct PluginListResponse { pub marketplaces: Vec, #[serde(default)] pub marketplace_load_errors: Vec, - pub remote_sync_error: Option, #[serde(default)] pub featured_plugin_ids: Vec, } @@ -3489,7 +3484,10 @@ pub struct MarketplaceLoadErrorInfo { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginReadParams { - pub marketplace_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, pub plugin_name: String, } @@ -3601,7 +3599,9 @@ pub struct SkillsListEntry { #[ts(export_to = "v2/")] pub struct PluginMarketplaceEntry { pub name: String, - pub path: AbsolutePathBuf, + /// Local marketplace file path when the marketplace is backed by a local file. + /// Remote-only catalog marketplaces do not have a local path. + pub path: Option, pub interface: Option, pub plugins: Vec, } @@ -3694,9 +3694,18 @@ pub struct PluginInterface { /// 128 characters per entry. pub default_prompt: Option>, pub brand_color: Option, + /// Local composer icon path, resolved from the installed plugin package. pub composer_icon: Option, + /// Remote composer icon URL from the plugin catalog. + pub composer_icon_url: Option, + /// Local logo path, resolved from the installed plugin package. pub logo: Option, + /// Remote logo URL from the plugin catalog. + pub logo_url: Option, + /// Local screenshot paths, resolved from the installed plugin package. pub screenshots: Vec, + /// Remote screenshot URLs from the plugin catalog. + pub screenshot_urls: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3715,6 +3724,9 @@ pub enum PluginSource { ref_name: Option, sha: Option, }, + /// The plugin is available in the remote catalog. Download metadata is + /// kept server-side and is not exposed through the app-server API. + Remote, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3741,11 +3753,11 @@ pub struct SkillsConfigWriteResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginInstallParams { - pub marketplace_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, pub plugin_name: String, - /// When true, apply the remote plugin change before the local install flow. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3761,9 +3773,6 @@ pub struct PluginInstallResponse { #[ts(export_to = "v2/")] pub struct PluginUninstallParams { pub plugin_id: String, - /// When true, apply the remote plugin change before the local uninstall flow. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -8556,27 +8565,44 @@ mod tests { } #[test] - fn plugin_list_params_serialization_uses_force_remote_sync() { + fn plugin_source_serializes_local_git_and_remote_variants() { + let local_path = if cfg!(windows) { + r"C:\plugins\linear" + } else { + "/plugins/linear" + }; + let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); + let local_path_json = local_path.as_path().display().to_string(); + assert_eq!( - serde_json::to_value(PluginListParams { - cwds: None, - force_remote_sync: false, - }) - .unwrap(), + serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), json!({ - "cwds": null, + "type": "local", + "path": local_path_json, }), ); assert_eq!( - serde_json::to_value(PluginListParams { - cwds: None, - force_remote_sync: true, + serde_json::to_value(PluginSource::Git { + url: "https://github.com/openai/example.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), }) .unwrap(), json!({ - "cwds": null, - "forceRemoteSync": true, + "type": "git", + "url": "https://github.com/openai/example.git", + "path": "plugins/example", + "refName": "main", + "sha": "abc123", + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Remote).unwrap(), + json!({ + "type": "remote", }), ); } @@ -8613,7 +8639,143 @@ mod tests { } #[test] - fn plugin_install_params_serialization_uses_force_remote_sync() { + fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { + assert_eq!( + serde_json::to_value(PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: None, + interface: None, + plugins: Vec::new(), + }) + .unwrap(), + json!({ + "name": "openai-curated", + "path": null, + "interface": null, + "plugins": [], + }), + ); + } + + #[test] + fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { + let composer_icon = if cfg!(windows) { + r"C:\plugins\linear\icon.png" + } else { + "/plugins/linear/icon.png" + }; + let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); + let composer_icon_json = composer_icon.as_path().display().to_string(); + + let interface = PluginInterface { + display_name: Some("Linear".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Productivity".to_string()), + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some(composer_icon), + composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), + logo: None, + logo_url: Some("https://example.com/linear/logo.png".to_string()), + screenshots: Vec::new(), + screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], + }; + + assert_eq!( + serde_json::to_value(interface).unwrap(), + json!({ + "displayName": "Linear", + "shortDescription": null, + "longDescription": null, + "developerName": null, + "category": "Productivity", + "capabilities": [], + "websiteUrl": null, + "privacyPolicyUrl": null, + "termsOfServiceUrl": null, + "defaultPrompt": null, + "brandColor": null, + "composerIcon": composer_icon_json, + "composerIconUrl": "https://example.com/linear/icon.png", + "logo": null, + "logoUrl": "https://example.com/linear/logo.png", + "screenshots": [], + "screenshotUrls": ["https://example.com/linear/screenshot.png"], + }), + ); + } + + #[test] + fn plugin_list_params_ignore_removed_force_remote_sync_field() { + assert_eq!( + serde_json::from_value::(json!({ + "cwds": null, + "forceRemoteSync": true, + })) + .unwrap(), + PluginListParams { cwds: None }, + ); + } + + #[test] + fn plugin_read_params_serialization_uses_install_source_fields() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + })) + .unwrap(), + PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); + } + + #[test] + fn plugin_install_params_serialization_omits_force_remote_sync() { let marketplace_path = if cfg!(windows) { r"C:\plugins\marketplace.json" } else { @@ -8623,38 +8785,52 @@ mod tests { let marketplace_path_json = marketplace_path.as_path().display().to_string(); assert_eq!( serde_json::to_value(PluginInstallParams { - marketplace_path: marketplace_path.clone(), + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, plugin_name: "gmail".to_string(), - force_remote_sync: false, }) .unwrap(), json!({ "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, "pluginName": "gmail", }), ); assert_eq!( - serde_json::to_value(PluginInstallParams { - marketplace_path, - plugin_name: "gmail".to_string(), - force_remote_sync: true, - }) - .unwrap(), - json!({ + serde_json::from_value::(json!({ "marketplacePath": marketplace_path_json, "pluginName": "gmail", "forceRemoteSync": true, - }), + })) + .unwrap(), + PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, ); } #[test] - fn plugin_uninstall_params_serialization_uses_force_remote_sync() { + fn plugin_uninstall_params_serialization_omits_force_remote_sync() { assert_eq!( serde_json::to_value(PluginUninstallParams { plugin_id: "gmail@openai-curated".to_string(), - force_remote_sync: false, }) .unwrap(), json!({ @@ -8663,15 +8839,14 @@ mod tests { ); assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - force_remote_sync: true, - }) - .unwrap(), - json!({ + serde_json::from_value::(json!({ "pluginId": "gmail@openai-curated", "forceRemoteSync": true, - }), + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }, ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88156f5532..fcc19ce42d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -185,7 +185,7 @@ Example with notification opt-out: - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). 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`). - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e3f6322d40..aa058c583e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6428,55 +6428,19 @@ impl CodexMessageProcessor { async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { let plugins_manager = self.thread_manager.plugins_manager(); - let PluginListParams { - cwds, - force_remote_sync, - } = params; + let PluginListParams { cwds } = params; let roots = cwds.unwrap_or_default(); plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); - let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await { + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => config, Err(err) => { self.outgoing.send_error(request_id, err).await; return; } }; - let mut remote_sync_error = None; let auth = self.auth_manager.auth().await; - if force_remote_sync { - match plugins_manager - .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ false) - .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 plugin/list remote sync" - ); - } - Err(err) => { - warn!( - error = %err, - "plugin/list remote sync failed; returning local marketplace state" - ); - remote_sync_error = Some(err.to_string()); - } - } - - config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - } - let config_for_marketplace_listing = config.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { @@ -6494,7 +6458,7 @@ impl CodexMessageProcessor { .into_iter() .map(|marketplace| PluginMarketplaceEntry { name: marketplace.name, - path: marketplace.path, + path: Some(marketplace.path), interface: marketplace.interface.map(|interface| MarketplaceInterface { display_name: interface.display_name, }), @@ -6509,7 +6473,7 @@ impl CodexMessageProcessor { source: marketplace_plugin_source_to_info(plugin.source), install_policy: plugin.policy.installation.into(), auth_policy: plugin.policy.authentication.into(), - interface: plugin.interface.map(plugin_interface_to_info), + interface: plugin.interface.map(local_plugin_interface_to_info), }) .collect(), }) @@ -6569,7 +6533,6 @@ impl CodexMessageProcessor { PluginListResponse { marketplaces: data, marketplace_load_errors, - remote_sync_error, featured_plugin_ids, }, ) @@ -6613,8 +6576,40 @@ impl CodexMessageProcessor { let plugins_manager = self.thread_manager.plugins_manager(); let PluginReadParams { marketplace_path, + remote_marketplace_name, plugin_name, } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "remote plugin read is not supported yet for marketplace {remote_marketplace_name}" + ), + data: None, + }, + ) + .await; + return; + } + (Some(_), Some(_)) | (None, None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), + data: None, + }, + ) + .await; + return; + } + }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); let config = match self.load_latest_config(config_cwd).await { @@ -6664,7 +6659,7 @@ impl CodexMessageProcessor { enabled: outcome.plugin.enabled, install_policy: outcome.plugin.policy.installation.into(), auth_policy: outcome.plugin.policy.authentication.into(), - interface: outcome.plugin.interface.map(plugin_interface_to_info), + interface: outcome.plugin.interface.map(local_plugin_interface_to_info), }, description: outcome.plugin.description, skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), @@ -6738,9 +6733,40 @@ impl CodexMessageProcessor { async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) { let PluginInstallParams { marketplace_path, + remote_marketplace_name, plugin_name, - force_remote_sync, } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "remote plugin install is not supported yet for marketplace {remote_marketplace_name}" + ), + data: None, + }, + ) + .await; + return; + } + (Some(_), Some(_)) | (None, None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), + data: None, + }, + ) + .await; + return; + } + }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); let plugins_manager = self.thread_manager.plugins_manager(); @@ -6749,21 +6775,7 @@ impl CodexMessageProcessor { marketplace_path, }; - let install_result = if force_remote_sync { - let config = match self.load_latest_config(config_cwd.clone()).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - let auth = self.auth_manager.auth().await; - plugins_manager - .install_plugin_with_remote_sync(&config, auth.as_ref(), request) - .await - } else { - plugins_manager.install_plugin(request).await - }; + let install_result = plugins_manager.install_plugin(request).await; match install_result { Ok(result) => { @@ -6915,27 +6927,10 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: PluginUninstallParams, ) { - let PluginUninstallParams { - plugin_id, - force_remote_sync, - } = params; + let PluginUninstallParams { plugin_id } = params; let plugins_manager = self.thread_manager.plugins_manager(); - let uninstall_result = if force_remote_sync { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - let auth = self.auth_manager.auth().await; - plugins_manager - .uninstall_plugin_with_remote_sync(&config, auth.as_ref(), plugin_id) - .await - } else { - plugins_manager.uninstall_plugin(plugin_id).await - }; + let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await; match uninstall_result { Ok(()) => { @@ -9105,7 +9100,7 @@ fn plugin_skills_to_info( .collect() } -fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { +fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { PluginInterface { display_name: interface.display_name, short_description: interface.short_description, @@ -9119,8 +9114,11 @@ fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterfa default_prompt: interface.default_prompt, brand_color: interface.brand_color, composer_icon: interface.composer_icon, + composer_icon_url: None, logo: interface.logo, + logo_url: None, screenshots: interface.screenshots, + screenshot_urls: Vec::new(), } } 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 21acf24906..049256b602 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 @@ -98,10 +98,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl assert_eq!(notification.method, "externalAgentConfig/import/completed"); let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index e51fac725f..3555dd745b 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -44,12 +44,6 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; 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; // Plugin install tests wait on connector discovery after the install response path // starts, which is noticeably slower on Windows CI. @@ -82,6 +76,97 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_missing_install_source() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("marketplace.json"), + )?), + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_remote_marketplace_until_remote_install_is_supported() -> Result<()> +{ + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin install is not supported yet") + ); + assert!(err.error.message.contains("openai-curated")); + Ok(()) +} + #[tokio::test] async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { let codex_home = TempDir::new()?; @@ -90,11 +175,11 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() - let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("missing-marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "missing-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -131,9 +216,9 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -181,9 +266,9 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -198,76 +283,6 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() Ok(()) } -#[tokio::test] -async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()> -{ - let server = MockServer::start().await; - let codex_home = TempDir::new()?; - write_plugin_remote_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, - )?; - - let repo_root = TempDir::new()?; - write_plugin_marketplace( - repo_root.path(), - "debug", - "sample-plugin", - "./sample-plugin", - /*install_policy*/ None, - /*auth_policy*/ None, - )?; - write_plugin_source(repo_root.path(), "sample-plugin", &[])?; - let marketplace_path = - AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - - Mock::given(method("POST")) - .and(path("/backend-api/plugins/sample-plugin@debug/enable")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#), - ) - .expect(1) - .mount(&server) - .await; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_install_request(PluginInstallParams { - marketplace_path, - plugin_name: "sample-plugin".to_string(), - force_remote_sync: true, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginInstallResponse = to_response(response)?; - assert_eq!(response.apps_needing_auth, Vec::::new()); - - assert!( - codex_home - .path() - .join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json") - .is_file() - ); - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); - assert!(config.contains("enabled = true")); - Ok(()) -} - #[tokio::test] async fn plugin_install_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; @@ -300,9 +315,9 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( @@ -415,9 +430,9 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -499,9 +514,9 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -566,9 +581,9 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( @@ -758,23 +773,6 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std:: ) } -fn write_plugin_remote_sync_config( - codex_home: &std::path::Path, - base_url: &str, -) -> std::io::Result<()> { - std::fs::write( - codex_home.join("config.toml"), - format!( - r#" -chatgpt_base_url = "{base_url}" - -[features] -plugins = true -"# - ), - ) -} - fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, 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 28056e9ec7..bf69df3c47 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -71,7 +71,6 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -86,7 +85,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul response .marketplaces .iter() - .all(|marketplace| { marketplace.path != marketplace_path }), + .all(|marketplace| { marketplace.path.as_ref() != Some(&marketplace_path) }), "invalid marketplace should be skipped" ); assert_eq!(response.marketplace_load_errors.len(), 1); @@ -200,7 +199,6 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ AbsolutePathBuf::try_from(valid_repo_root.path())?, AbsolutePathBuf::try_from(invalid_repo_root.path())?, ]), - force_remote_sync: false, }) .await?; @@ -215,7 +213,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ response.marketplaces, vec![PluginMarketplaceEntry { name: "valid-marketplace".to_string(), - path: valid_marketplace_path, + path: Some(valid_marketplace_path), interface: None, plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), @@ -243,7 +241,6 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ "unexpected error: {:?}", response.marketplace_load_errors ); - assert_eq!(response.remote_sync_error, None); assert!(response.featured_plugin_ids.is_empty()); Ok(()) } @@ -314,7 +311,6 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -329,7 +325,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab response.marketplaces, vec![PluginMarketplaceEntry { name: "alternate-marketplace".to_string(), - path: marketplace_path, + path: Some(marketplace_path), interface: None, plugins: vec![ PluginSummary { @@ -355,8 +351,11 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab default_prompt: None, brand_color: None, composer_icon: None, + composer_icon_url: None, logo: None, + logo_url: None, screenshots: Vec::new(), + screenshot_urls: Vec::new(), }), }, PluginSummary { @@ -412,10 +411,7 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( @@ -486,7 +482,6 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -501,11 +496,13 @@ enabled = false .marketplaces .into_iter() .find(|marketplace| { - marketplace.path - == AbsolutePathBuf::try_from( - repo_root.path().join(".agents/plugins/marketplace.json"), + marketplace.path.as_ref() + == Some( + &AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + ) + .expect("absolute marketplace path"), ) - .expect("absolute marketplace path") }) .expect("expected repo marketplace entry"); @@ -641,7 +638,6 @@ enabled = false AbsolutePathBuf::try_from(workspace_enabled.path())?, AbsolutePathBuf::try_from(workspace_default.path())?, ]), - force_remote_sync: false, }) .await?; @@ -725,7 +721,6 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -838,7 +833,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -865,163 +859,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { Ok(()) } -#[tokio::test] -async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> { - let codex_home = TempDir::new()?; - write_plugin_sync_config(codex_home.path(), "https://chatgpt.com/backend-api/")?; - write_openai_curated_marketplace(codex_home.path(), &["linear"])?; - write_installed_plugin(&codex_home, "openai-curated", "linear")?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: true, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - - assert!( - response - .remote_sync_error - .as_deref() - .is_some_and(|message| message.contains("chatgpt authentication required")) - ); - 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, false)] - ); - Ok(()) -} - -#[tokio::test] -async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> 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", "gmail", "calendar"])?; - write_installed_plugin(&codex_home, "openai-curated", "linear")?; - write_installed_plugin(&codex_home, "openai-curated", "gmail")?; - write_installed_plugin(&codex_home, "openai-curated", "calendar")?; - - 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}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .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","calendar@openai-curated"]"#), - ) - .mount(&server) - .await; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: true, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - assert_eq!(response.remote_sync_error, None); - assert_eq!( - response.featured_plugin_ids, - vec![ - "linear@openai-curated".to_string(), - "calendar@openai-curated".to_string(), - ] - ); - - 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), - ("gmail@openai-curated".to_string(), false, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - 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!( - codex_home - .path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !codex_home - .path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - assert!( - !codex_home - .path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - Ok(()) -} - #[tokio::test] async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { let codex_home = TempDir::new()?; @@ -1069,10 +906,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) .await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1128,10 +962,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( @@ -1145,7 +976,6 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul response.featured_plugin_ids, vec!["linear@openai-curated".to_string()] ); - assert_eq!(response.remote_sync_error, None); Ok(()) } @@ -1169,10 +999,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( @@ -1186,7 +1013,6 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> response.featured_plugin_ids, vec!["linear@openai-curated".to_string()] ); - assert_eq!(response.remote_sync_error, None); Ok(()) } 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 20114e79ba..f5681e9d4d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -45,6 +45,96 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +#[tokio::test] +async fn plugin_read_rejects_missing_read_source() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("marketplace.json"), + )?), + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin read is not supported yet") + ); + assert!(err.error.message.contains("openai-curated")); + Ok(()) +} + #[tokio::test] async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> { let codex_home = TempDir::new()?; @@ -179,7 +269,8 @@ enabled = true AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: marketplace_path.clone(), + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, plugin_name: "demo-plugin".to_string(), }) .await?; @@ -326,7 +417,8 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), }) .await?; @@ -392,9 +484,10 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "demo-plugin".to_string(), }) .await?; @@ -446,9 +539,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result< let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "missing-plugin".to_string(), }) .await?; @@ -498,9 +592,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() - let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "demo-plugin".to_string(), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 00fabe4832..512cce3994 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -16,12 +16,6 @@ use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; 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; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -44,7 +38,6 @@ enabled = true let params = PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), - force_remote_sync: false, }; let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; @@ -77,74 +70,6 @@ enabled = true Ok(()) } -#[tokio::test] -async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> { - let server = MockServer::start().await; - let codex_home = TempDir::new()?; - write_installed_plugin(&codex_home, "debug", "sample-plugin")?; - std::fs::write( - codex_home.path().join("config.toml"), - format!( - r#"chatgpt_base_url = "{}/backend-api/" - -[features] -plugins = true - -[plugins."sample-plugin@debug"] -enabled = true -"#, - 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, - )?; - - Mock::given(method("POST")) - .and(path("/backend-api/plugins/sample-plugin@debug/uninstall")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#), - ) - .expect(1) - .mount(&server) - .await; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_uninstall_request(PluginUninstallParams { - plugin_id: "sample-plugin@debug".to_string(), - force_remote_sync: true, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginUninstallResponse = to_response(response)?; - assert_eq!(response, PluginUninstallResponse {}); - - assert!( - !codex_home - .path() - .join("plugins/cache/debug/sample-plugin") - .exists() - ); - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); - Ok(()) -} - #[tokio::test] async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; @@ -172,7 +97,6 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), - force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d3afbf8978..2be36210e7 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -565,9 +565,7 @@ impl PluginsManager { self.restriction_product, )?; let plugin_id = resolved.plugin_id.as_key(); - // This only forwards the backend mutation before the local install flow. We rely on - // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra - // reconcile pass here. + // This only forwards the backend mutation before the local install flow. codex_core_plugins::remote::enable_remote_plugin( &remote_plugin_service_config(config), auth, @@ -655,11 +653,11 @@ impl PluginsManager { auth: Option<&CodexAuth>, plugin_id: String, ) -> Result<(), PluginUninstallError> { + // TODO: Remove this legacy remote-sync path once remote plugins have + // their own manager and installed-state API. let plugin_id = PluginId::parse(&plugin_id)?; let plugin_key = plugin_id.as_key(); - // This only forwards the backend mutation before the local uninstall flow. We rely on - // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra - // reconcile pass here. + // This only forwards the backend mutation before the local uninstall flow. codex_core_plugins::remote::uninstall_remote_plugin( &remote_plugin_service_config(config), auth, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7254272138..6a2fff4397 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4772,7 +4772,8 @@ impl App { app_server, cwd, PluginReadParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name, }, ); @@ -6502,7 +6503,6 @@ async fn fetch_plugins_list( request_id, params: PluginListParams { cwds: Some(vec![cwd]), - force_remote_sync: false, }, }) .await @@ -6540,9 +6540,9 @@ async fn fetch_plugin_install( .request_typed(ClientRequest::PluginInstall { request_id, params: PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name, - force_remote_sync: false, }, }) .await @@ -6557,10 +6557,7 @@ async fn fetch_plugin_uninstall( request_handle .request_typed(ClientRequest::PluginUninstall { request_id, - params: PluginUninstallParams { - plugin_id, - force_remote_sync: false, - }, + params: PluginUninstallParams { plugin_id }, }) .await .wrap_err("plugin/uninstall failed in TUI") @@ -6759,19 +6756,18 @@ mod tests { marketplaces: vec![ PluginMarketplaceEntry { name: "openai-bundled".to_string(), - path: test_absolute_path("/marketplaces/openai-bundled"), + path: Some(test_absolute_path("/marketplaces/openai-bundled")), interface: None, plugins: Vec::new(), }, PluginMarketplaceEntry { name: "openai-curated".to_string(), - path: test_absolute_path("/marketplaces/openai-curated"), + path: Some(test_absolute_path("/marketplaces/openai-curated")), interface: None, plugins: Vec::new(), }, ], marketplace_load_errors: Vec::new(), - remote_sync_error: None, featured_plugin_ids: Vec::new(), }; @@ -6781,7 +6777,7 @@ mod tests { response.marketplaces, vec![PluginMarketplaceEntry { name: "openai-curated".to_string(), - path: test_absolute_path("/marketplaces/openai-curated"), + path: Some(test_absolute_path("/marketplaces/openai-curated")), interface: None, plugins: Vec::new(), }] diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 964a986314..bf5a930e2d 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -5,6 +5,7 @@ use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionTab; @@ -760,7 +761,6 @@ impl ChatWidget { header: plugins_header( "Browse plugins from available marketplaces.".to_string(), format!("Installed {installed} of {total} available plugins."), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( all_entries, @@ -776,7 +776,6 @@ impl ChatWidget { header: plugins_header( "Installed plugins.".to_string(), format!("Showing {installed} installed plugins."), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( installed_entries, @@ -804,7 +803,6 @@ impl ChatWidget { header: plugins_header( "OpenAI Curated marketplace.".to_string(), format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( curated_entries, @@ -848,7 +846,6 @@ impl ChatWidget { format!( "Installed {marketplace_installed} of {marketplace_total} {label} plugins." ), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( entries, @@ -1047,24 +1044,35 @@ impl ChatWidget { let plugin_display_name = display_name.clone(); let marketplace_path = marketplace.path.clone(); let plugin_name = plugin.name.clone(); - - items.push(SelectionItem { - name: display_name, - description: Some(description), - selected_description: Some(selected_description), - search_value: Some(search_value), - actions: vec![Box::new(move |tx| { + let is_disabled = marketplace_path.is_none(); + let actions: Vec = if let Some(marketplace_path) = marketplace_path { + vec![Box::new(move |tx| { tx.send(AppEvent::OpenPluginDetailLoading { plugin_display_name: plugin_display_name.clone(), }); tx.send(AppEvent::FetchPluginDetail { cwd: cwd.clone(), params: codex_app_server_protocol::PluginReadParams { - marketplace_path: marketplace_path.clone(), + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, plugin_name: plugin_name.clone(), }, }); - })], + })] + } else { + Vec::new() + }; + let disabled_reason = + is_disabled.then(|| "remote plugin details are not available yet".to_string()); + + items.push(SelectionItem { + name: display_name, + description: Some(description), + selected_description: Some(selected_description), + search_value: Some(search_value), + actions, + is_disabled, + disabled_reason, ..Default::default() }); } @@ -1089,20 +1097,11 @@ fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } -fn plugins_header( - subtitle: String, - count_line: String, - remote_sync_error: Option<&str>, -) -> Box { +fn plugins_header(subtitle: String, count_line: String) -> Box { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from(subtitle.dim())); header.push(Line::from(count_line.dim())); - if let Some(remote_sync_error) = remote_sync_error { - header.push(Line::from( - format!("Using cached marketplace data: {remote_sync_error}").dim(), - )); - } Box::new(header) } @@ -1138,7 +1137,10 @@ fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, } fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - format!("marketplace:{}", marketplace.path.display()) + match marketplace.path.as_ref() { + Some(path) => format!("marketplace:{}", path.display()), + None => format!("marketplace:{}", marketplace.name), + } } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index e43251eb9d..ee8c81db3f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -1,11 +1,10 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Plugins Browse plugins from available marketplaces. Installed 1 of 4 available plugins. - Using cached marketplace data: remote sync timed out [All Plugins] Installed (1) OpenAI Curated Repo Marketplace diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 89de317f72..66df972ebf 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -859,8 +859,11 @@ pub(super) fn plugins_test_interface( default_prompt: None, brand_color: None, composer_icon: None, + composer_icon_url: None, logo: None, + logo_url: None, screenshots: Vec::new(), + screenshot_urls: Vec::new(), } } @@ -896,7 +899,7 @@ pub(super) fn plugins_test_curated_marketplace( ) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), - path: plugins_test_absolute_path("marketplaces/chatgpt"), + path: Some(plugins_test_absolute_path("marketplaces/chatgpt")), interface: Some(MarketplaceInterface { display_name: Some("ChatGPT Marketplace".to_string()), }), @@ -907,7 +910,7 @@ pub(super) fn plugins_test_curated_marketplace( pub(super) fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: "repo".to_string(), - path: plugins_test_absolute_path("marketplaces/repo"), + path: Some(plugins_test_absolute_path("marketplaces/repo")), interface: Some(MarketplaceInterface { display_name: Some("Repo Marketplace".to_string()), }), @@ -921,7 +924,6 @@ pub(super) fn plugins_test_response( PluginListResponse { marketplaces, marketplace_load_errors: Vec::new(), - remote_sync_error: None, featured_plugin_ids: Vec::new(), } } 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 859be3b94a..e55881d3f7 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -105,7 +105,7 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); - let mut response = plugins_test_response(vec![ + let response = plugins_test_response(vec![ plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-bravo", @@ -145,8 +145,6 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ PluginInstallPolicy::Available, )]), ]); - response.remote_sync_error = Some("remote sync timed out".to_string()); - let popup = render_loaded_plugins_popup(&mut chat, response); assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup); assert!( @@ -544,7 +542,9 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { let response = plugins_test_response(vec![ PluginMarketplaceEntry { name: "duplicate".to_string(), - path: plugins_test_absolute_path("marketplaces/home/marketplace.json"), + path: Some(plugins_test_absolute_path( + "marketplaces/home/marketplace.json", + )), interface: Some(MarketplaceInterface { display_name: Some("Duplicate Marketplace".to_string()), }), @@ -560,7 +560,9 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { }, PluginMarketplaceEntry { name: "duplicate".to_string(), - path: plugins_test_absolute_path("marketplaces/repo/marketplace.json"), + path: Some(plugins_test_absolute_path( + "marketplaces/repo/marketplace.json", + )), interface: Some(MarketplaceInterface { display_name: Some("Duplicate Marketplace".to_string()), }), From 06f8ec54db9e12a82656f4a7ebe5e154a83db9e4 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Fri, 17 Apr 2026 17:33:11 -0700 Subject: [PATCH 06/18] /plugins: Add inline enablement toggles (#18395) This PR adds inline enable/disable controls to the new /plugins browse menu. Installed plugins can now be toggled directly from the list with keyboard interaction, and the associated config-write plumbing is included so the UI and persisted plugin state stay in sync. This also includes the queued-write handling needed to avoid stale toggle completions overwriting newer intent. - Add toggleable plugin rows for installed plugins in /plugins - Support Space to enable or disable without leaving the list - Persist plugin enablement through the existing app/config write path - Preserve the current selection while the list refreshes after a toggle - Add tests and snapshot updates for toggling behavior --------- Co-authored-by: Codex --- codex-rs/tui/src/app.rs | 120 ++++++++++++++ codex-rs/tui/src/app_event.rs | 15 ++ .../src/bottom_pane/list_selection_view.rs | 115 +++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget/plugins.rs | 105 ++++++++++-- ...ts__plugins_popup_curated_marketplace.snap | 10 +- ..._tests__plugins_popup_search_filtered.snap | 4 +- .../chatwidget/tests/popups_and_settings.rs | 156 +++++++++++++++++- 8 files changed, 503 insertions(+), 23 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6a2fff4397..5a380bd3f7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,6 +75,8 @@ use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; @@ -82,6 +84,7 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; @@ -1045,6 +1048,10 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + // Serialize plugin enablement writes per plugin so stale completions cannot + // overwrite a newer toggle, even if the plugin is toggled from different + // cwd contexts. + pending_plugin_enabled_writes: HashMap>, } #[derive(Default)] @@ -2170,6 +2177,48 @@ impl App { }); } + fn set_plugin_enabled( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + ) { + if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&plugin_id) { + *queued_enabled = Some(enabled); + return; + } + + self.pending_plugin_enabled_writes + .insert(plugin_id.clone(), None); + self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled); + } + + fn spawn_plugin_enabled_write( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = write_plugin_enabled(request_handle, plugin_id, enabled) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to update plugin config: {err}")); + app_event_tx.send(AppEvent::PluginEnabledSet { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + enabled, + result, + }); + }); + } + fn refresh_plugin_mentions(&mut self) { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); @@ -4051,6 +4100,7 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -4742,6 +4792,13 @@ impl App { } => { self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); } + AppEvent::SetPluginEnabled { + cwd, + plugin_id, + enabled, + } => { + self.set_plugin_enabled(app_server, cwd, plugin_id, enabled); + } AppEvent::PluginInstallLoaded { cwd, marketplace_path, @@ -4780,6 +4837,46 @@ impl App { } } } + AppEvent::PluginEnabledSet { + cwd, + plugin_id, + enabled, + result, + } => { + let queued_enabled = self + .pending_plugin_enabled_writes + .get_mut(&plugin_id) + .and_then(Option::take); + let should_apply_result = if let Some(queued_enabled) = queued_enabled + && (result.is_err() || queued_enabled != enabled) + { + self.spawn_plugin_enabled_write( + app_server, + cwd.clone(), + plugin_id.clone(), + queued_enabled, + ); + false + } else { + true + }; + if should_apply_result { + self.pending_plugin_enabled_writes.remove(&plugin_id); + let update_succeeded = result.is_ok(); + if update_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin toggle" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget + .on_plugin_enabled_set(cwd, plugin_id, enabled, result); + } + } AppEvent::FetchMcpInventory => { self.fetch_mcp_inventory(app_server); } @@ -6563,6 +6660,27 @@ async fn fetch_plugin_uninstall( .wrap_err("plugin/uninstall failed in TUI") } +async fn write_plugin_enabled( + request_handle: AppServerRequestHandle, + plugin_id: String, + enabled: bool, +) -> Result { + let request_id = RequestId::String(format!("plugin-enable-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConfigValueWrite { + request_id, + params: ConfigValueWriteParams { + key_path: format!("plugins.{plugin_id}"), + value: serde_json::json!({ "enabled": enabled }), + merge_strategy: MergeStrategy::Upsert, + file_path: None, + expected_version: None, + }, + }) + .await + .wrap_err("config/value/write failed while updating plugin enablement in TUI") +} + fn build_feedback_upload_params( origin_thread_id: Option, rollout_path: Option, @@ -9813,6 +9931,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), } } @@ -9870,6 +9989,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 106bb7a912..3f138b219d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -283,6 +283,21 @@ pub(crate) enum AppEvent { result: Result, }, + /// Enable or disable an installed plugin. + SetPluginEnabled { + cwd: PathBuf, + plugin_id: String, + enabled: bool, + }, + + /// Result of enabling or disabling a plugin. + PluginEnabledSet { + cwd: PathBuf, + plugin_id: String, + enabled: bool, + result: Result<(), String>, + }, + /// Refresh plugin mention bindings from the current config. RefreshPluginMentions, diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index eecf1bc580..bbcd2f1c7e 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -101,6 +101,12 @@ pub(crate) enum SelectionRowDisplay { /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +pub(crate) type SelectionToggleAction = dyn Fn(bool, &AppEventSender) + Send + Sync; + +pub(crate) struct SelectionToggle { + pub is_on: bool, + pub action: Box, +} /// Callback invoked whenever the highlighted item changes (arrow keys, search /// filter, number-key jump). Receives the *actual* index into the unfiltered @@ -122,6 +128,8 @@ pub(crate) type OnCancelCallback = Option>, + pub toggle: Option, + pub toggle_placeholder: Option<&'static str>, pub display_shortcut: Option, pub description: Option, pub selected_description: Option, @@ -345,6 +353,15 @@ impl ListSelectionView { .unwrap_or(self.items.as_slice()) } + fn active_items_mut(&mut self) -> &mut [SelectionItem] { + if let Some(idx) = self.active_tab_idx + && let Some(tab) = self.tabs.get_mut(idx) + { + return tab.items.as_mut_slice(); + } + self.items.as_mut_slice() + } + fn active_header(&self) -> &dyn Renderable { self.active_tab_idx .and_then(|idx| self.tabs.get(idx)) @@ -454,6 +471,11 @@ impl ListSelectionView { let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); let mut name_prefix_spans = Vec::new(); name_prefix_spans.push(wrap_prefix.into()); + if let Some(toggle) = &item.toggle { + name_prefix_spans.push(if toggle.is_on { "[*] " } else { "[ ] " }.into()); + } else if let Some(placeholder) = item.toggle_placeholder { + name_prefix_spans.push(placeholder.into()); + } name_prefix_spans.extend(item.name_prefix_spans.clone()); let description = is_selected .then(|| item.selected_description.clone()) @@ -514,6 +536,44 @@ impl ListSelectionView { self.state.scroll_top = 0; } + fn selected_item_has_toggle(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_some() && item.disabled_reason.is_none() && !item.is_disabled + }) + } + + fn selected_item_has_toggle_placeholder(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_none() + && item.toggle_placeholder.is_some() + && item.disabled_reason.is_none() + && !item.is_disabled + }) + } + + fn toggle_selected(&mut self) { + let Some(actual_idx) = self.selected_actual_idx() else { + return; + }; + let app_event_tx = self.app_event_tx.clone(); + let Some(item) = self.active_items_mut().get_mut(actual_idx) else { + return; + }; + if item.is_disabled || item.disabled_reason.is_some() { + return; + } + let Some(toggle) = item.toggle.as_mut() else { + return; + }; + + toggle.is_on = !toggle.is_on; + (toggle.action)(toggle.is_on, &app_event_tx); + } + fn move_up(&mut self) { let before = self.selected_actual_idx(); let len = self.visible_len(); @@ -742,6 +802,22 @@ impl BottomPaneView for ListSelectionView { self.search_query.pop(); self.apply_filter(); } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.selected_item_has_toggle() + && (!self.is_searchable || self.search_query.is_empty()) => + { + self.toggle_selected() + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.is_searchable + && self.search_query.is_empty() + && self.selected_item_has_toggle_placeholder() => {} KeyEvent { code: KeyCode::Esc, .. } => { @@ -1536,6 +1612,45 @@ mod tests { assert_eq!(view.selected_actual_idx(), Some(1)); } + #[test] + fn space_appends_to_active_search_instead_of_toggling_selected_item() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Plugin".to_string(), + toggle: Some(SelectionToggle { + is_on: false, + action: Box::new(|_enabled, tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + }), + }), + ..Default::default() + }], + is_searchable: true, + ..Default::default() + }, + tx, + ); + view.set_search_query("plugin".to_string()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(view.search_query, "plugin "); + assert!( + !view.active_items()[0] + .toggle + .as_ref() + .is_some_and(|toggle| toggle.is_on), + "expected Space to leave the toggle state unchanged while search is active" + ); + assert!( + rx.try_recv().is_err(), + "expected Space with an active search query to avoid firing the toggle action" + ); + } + #[test] fn single_line_row_display_truncates_instead_of_wrapping() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 427a77084e..6411f0182e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -91,6 +91,7 @@ mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::SelectionRowDisplay; +pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; pub(crate) use list_selection_view::SideContentWidth; pub(crate) use list_selection_view::popup_content_width; diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index bf5a930e2d..f4dfe6dbd9 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -9,6 +9,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::legacy_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; @@ -41,7 +42,7 @@ const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; -const PLUGIN_ROW_PREFIX_WIDTH: usize = 2; +const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); @@ -234,9 +235,12 @@ impl ChatWidget { fn open_plugins_popup(&mut self, response: &PluginListResponse) { self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string()); - self.bottom_pane.show_selection_view( - self.plugins_popup_params(response, self.plugins_active_tab_id.clone()), - ); + self.bottom_pane + .show_selection_view(self.plugins_popup_params( + response, + self.plugins_active_tab_id.clone(), + /*initial_selected_idx*/ None, + )); } pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { @@ -357,6 +361,49 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_enabled_set( + &mut self, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + result: Result<(), String>, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + if let Err(err) = result { + self.add_error_message(format!( + "Failed to update plugin config for {plugin_id}: {err}" + )); + if let PluginsCacheState::Ready(response) = self.plugins_cache_for_current_cwd() { + self.refresh_plugins_popup_if_open(&response); + } + return; + } + + let refreshed_response = match &mut self.plugins_cache { + PluginsCacheState::Ready(response) + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => + { + for plugin in response + .marketplaces + .iter_mut() + .flat_map(|marketplace| marketplace.plugins.iter_mut()) + .filter(|plugin| plugin.id == plugin_id) + { + plugin.enabled = enabled; + } + Some(response.clone()) + } + _ => None, + }; + + if let Some(response) = refreshed_response { + self.refresh_plugins_popup_if_open(&response); + } + } + pub(crate) fn on_plugin_uninstall_loaded( &mut self, cwd: PathBuf, @@ -564,7 +611,11 @@ impl ChatWidget { let tab_id = self.plugins_active_tab_id.clone(); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, - self.plugins_popup_params(&plugins_response, tab_id), + self.plugins_popup_params( + &plugins_response, + tab_id, + /*initial_selected_idx*/ None, + ), ); } } @@ -575,10 +626,13 @@ impl ChatWidget { .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) .map(str::to_string) .or_else(|| self.plugins_active_tab_id.clone()); + let selected_idx = self + .bottom_pane + .selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID); self.plugins_active_tab_id = active_tab_id.clone(); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, - self.plugins_popup_params(response, active_tab_id), + self.plugins_popup_params(response, active_tab_id, selected_idx), ); } @@ -728,6 +782,7 @@ impl ChatWidget { &self, response: &PluginListResponse, active_tab_id: Option, + initial_selected_idx: Option, ) -> SelectionViewParams { let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); @@ -867,6 +922,7 @@ impl ChatWidget { col_width_mode: ColumnWidthMode::AutoAllRows, row_display: SelectionRowDisplay::SingleLine, name_column_width, + initial_selected_idx, ..Default::default() } } @@ -1033,9 +1089,22 @@ impl ChatWidget { } else { plugin_brief_description_without_marketplace(plugin, status_label_width) }; + let can_view_details = marketplace.path.is_some(); let selected_status_label = format!("{status_label: = if let Some(marketplace_path) = marketplace_path { vec![Box::new(move |tx| { tx.send(AppEvent::OpenPluginDetailLoading { @@ -1062,11 +1142,14 @@ impl ChatWidget { } else { Vec::new() }; + let is_disabled = !can_view_details && !plugin.installed; let disabled_reason = is_disabled.then(|| "remote plugin details are not available yet".to_string()); items.push(SelectionItem { name: display_name, + toggle, + toggle_placeholder: (!plugin.installed).then_some("[-] "), description: Some(description), selected_description: Some(selected_description), search_value: Some(search_value), @@ -1090,7 +1173,7 @@ impl ChatWidget { } fn plugins_popup_hint_line() -> Line<'static> { - Line::from("←/→ select marketplace · enter view details · esc close") + Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close") } fn plugin_detail_hint_line() -> Line<'static> { @@ -1238,7 +1321,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available by default", + PluginInstallPolicy::InstalledByDefault => "Available", } } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index ee8c81db3f..e0f99b443e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -9,9 +9,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Type to search plugins -› Alpha Sync Disabled Press Enter to view plugin details. - Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - Starter Available by default · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. + [-] Starter Available · ChatGPT Marketplace · Included by default. - ←/→ select marketplace · enter view details · esc close + space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index 49f89be0bb..da1b0913fe 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -9,6 +9,6 @@ expression: popup [All Plugins] Installed (0) OpenAI Curated sla -› Slack Available Press Enter to view plugin details. +› [-] Slack Available Press Enter to view plugin details. - ←/→ select marketplace · enter view details · esc close + space enable/disable · ←/→ select marketplace · enter view details · esc close 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 e55881d3f7..a3792c31f4 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -247,7 +247,7 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { } #[tokio::test] -async fn plugins_popup_refresh_replaces_selection_with_first_row() { +async fn plugins_popup_refresh_preserves_selected_row_position() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); @@ -276,7 +276,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { let before = render_bottom_popup(&chat, /*width*/ 100); assert!( - before.contains("› Slack"), + before.contains("› [-] Slack"), "expected Slack to be selected before refresh, got:\n{before}" ); @@ -314,8 +314,12 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { let after = render_bottom_popup(&chat, /*width*/ 100); assert!( - after.contains("› Airtable"), - "expected refresh to rebuild the popup from the new first row, got:\n{after}" + after.contains("› [-] Notion"), + "expected refresh to preserve the selected row position, got:\n{after}" + ); + assert!( + after.contains("Airtable"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" ); assert!( after.contains("Slack"), @@ -387,11 +391,153 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected /plugins to refresh installed counts after install, got:\n{after}" ); assert!( - after.contains("Installed Press Enter to view plugin details."), + after.contains("Installed Space to disable; Enter view details."), "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" ); } +#[tokio::test] +async fn plugins_popup_space_toggles_installed_plugin_from_list() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let cwd = chat.config.cwd.to_path_buf(); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetPluginEnabled { + cwd: event_cwd, + plugin_id, + enabled, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(plugin_id, "plugin-drive"); + assert!(!enabled); + } + other => panic!("expected SetPluginEnabled event, got {other:?}"), + } + + chat.on_plugin_enabled_set( + cwd, + "plugin-drive".to_string(), + /*enabled*/ false, + Ok(()), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("› [ ] Drive"), + "expected selected plugin row to stay selected after refresh, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + let before = render_bottom_popup(&chat, /*width*/ 100); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let after = render_bottom_popup(&chat, /*width*/ 100); + + assert!( + rx.try_recv().is_err(), + "did not expect Space on an uninstalled plugin to emit an event" + ); + assert_eq!(after, before); +} + +#[tokio::test] +async fn plugins_popup_space_with_active_search_does_not_toggle_installed_plugin() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + type_plugins_search_query(&mut chat, "dr"); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert!( + rx.try_recv().is_err(), + "did not expect Space with an active plugin search to emit a toggle event" + ); +} + #[tokio::test] async fn plugins_popup_search_filters_visible_rows_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 370bed4bf4a0f5c5158ade9bfe8c0e13f773e724 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 17 Apr 2026 17:56:58 -0700 Subject: [PATCH 07/18] fix: trust-gate project hooks and exec policies (#14718) ## Summary - trust-gate project `.codex` layers consistently, including repos that have `.codex/hooks.json` or `.codex/execpolicy/*.rules` but no `.codex/config.toml` - keep disabled project layers in the config stack so nested trusted project layers still resolve correctly, while preventing hooks and exec policies from loading until the project is trusted - update app-server/TUI onboarding copy to make the trust boundary explicit and add regressions for loader, hooks, exec-policy, and onboarding coverage ## Security Before this change, an untrusted repo could auto-load project hooks or exec policies from `.codex/` as long as `config.toml` was absent. This makes trust the single gate for project-local config, hooks, and exec policies. ## Stack - Parent of #15936 ## Test - cargo test -p codex-core without_config_toml --------- Co-authored-by: Codex --- codex-rs/app-server/src/lib.rs | 27 ++- .../app-server/tests/suite/v2/thread_start.rs | 25 +-- codex-rs/config/src/config_toml.rs | 70 +++++-- codex-rs/core/src/config/config_tests.rs | 37 +++- codex-rs/core/src/config_loader/mod.rs | 151 +++++++++----- codex-rs/core/src/config_loader/tests.rs | 125 ++++++++++++ codex-rs/core/src/exec_policy.rs | 2 + codex-rs/core/src/exec_policy_tests.rs | 186 ++++++++++++++++++ codex-rs/core/src/session/tests.rs | 155 +++++++++++++++ codex-rs/tui/src/app.rs | 14 +- ..._tests__renders_snapshot_for_git_repo.snap | 4 +- .../tui/src/onboarding/trust_directory.rs | 11 +- 12 files changed, 684 insertions(+), 123 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ba946c67b6..65374a0d98 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -280,21 +280,16 @@ fn project_config_warning(config: &Config) -> Option ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ true, ) { - if !matches!(layer.name, ConfigLayerSource::Project { .. }) - || layer.disabled_reason.is_none() - { + let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else { continue; - } - if let ConfigLayerSource::Project { dot_codex_folder } = &layer.name { - disabled_folders.push(( - dot_codex_folder.as_path().display().to_string(), - layer - .disabled_reason - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "config.toml is disabled.".to_string()), - )); - } + }; + let Some(disabled_reason) = &layer.disabled_reason else { + continue; + }; + disabled_folders.push(( + dot_codex_folder.as_path().display().to_string(), + disabled_reason.clone(), + )); } if disabled_folders.is_empty() { @@ -302,8 +297,8 @@ fn project_config_warning(config: &Config) -> Option } let mut message = concat!( - "Project config.toml files are disabled in the following folders. ", - "Settings in those files are ignored, but skills and exec policies still load.\n", + "Project-local config, hooks, and exec policies are disabled in the following folders ", + "until the project is trusted, but skills still load.\n", ) .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { 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 508c77f7ff..9f212de14f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; +use codex_core::config_loader::project_trust_key; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; @@ -722,7 +723,8 @@ model_reasoning_effort = "high" let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &workspace_abs) .await .unwrap_or(workspace_abs); - assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path()))); + let trusted_root_key = project_trust_key(trusted_root.as_path()); + assert!(config_toml.contains(&trusted_root_key)); assert!(config_toml.contains("trust_level = \"trusted\"")); Ok(()) @@ -761,8 +763,10 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested_abs) .await .expect("git root should resolve"); - assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path()))); - assert!(!config_toml.contains(&persisted_trust_path(&nested))); + let trusted_root_key = project_trust_key(trusted_root.as_path()); + let nested_key = project_trust_key(&nested); + assert!(config_toml.contains(&trusted_root_key)); + assert!(!config_toml.contains(&nested_key)); Ok(()) } @@ -856,21 +860,6 @@ fn create_config_toml_without_approval_policy( ) } -fn persisted_trust_path(project_path: &Path) -> String { - let project_path = - std::fs::canonicalize(project_path).unwrap_or_else(|_| project_path.to_path_buf()); - let project_path = project_path.display().to_string(); - - if let Some(project_path) = project_path.strip_prefix(r"\\?\UNC\") { - return format!(r"\\{project_path}"); - } - - project_path - .strip_prefix(r"\\?\") - .unwrap_or(&project_path) - .to_string() -} - fn create_config_toml_with_optional_approval_policy( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 83fe6c88e7..6556ca73e3 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -684,25 +684,21 @@ impl ConfigToml { resolved_cwd: &Path, repo_root: Option<&Path>, ) -> Option { - let projects = self.projects.clone().unwrap_or_default(); + let projects = self.projects.as_ref()?; - let resolved_cwd_key = project_trust_key(resolved_cwd); - let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string(); - if let Some(project_config) = projects - .get(&resolved_cwd_key) - .or_else(|| projects.get(&resolved_cwd_raw_key)) - { - return Some(project_config.clone()); + for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) { + if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) { + return Some(project_config); + } } if let Some(repo_root) = repo_root { - let repo_root_key = project_trust_key(repo_root); - let repo_root_raw_key = repo_root.to_string_lossy().to_string(); - if let Some(project_config_for_root) = projects - .get(&repo_root_key) - .or_else(|| projects.get(&repo_root_raw_key)) - { - return Some(project_config_for_root.clone()); + for normalized_repo_root in normalized_project_lookup_keys(repo_root) { + if let Some(project_config_for_root) = + project_config_for_lookup_key(projects, &normalized_repo_root) + { + return Some(project_config_for_root); + } } } @@ -734,11 +730,45 @@ impl ConfigToml { /// Canonicalize the path and convert it to a string to be used as a key in the /// projects trust map. On Windows, strips UNC, when possible, to try to ensure /// that different paths that point to the same location have the same key. -fn project_trust_key(project_path: &Path) -> String { - normalize_for_path_comparison(project_path) - .unwrap_or_else(|_| project_path.to_path_buf()) - .to_string_lossy() - .to_string() +fn normalized_project_lookup_keys(path: &Path) -> Vec { + let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string()); + let normalized_canonical_path = normalize_project_lookup_key( + normalize_for_path_comparison(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(), + ); + if normalized_path == normalized_canonical_path { + vec![normalized_canonical_path] + } else { + vec![normalized_canonical_path, normalized_path] + } +} + +fn normalize_project_lookup_key(key: String) -> String { + if cfg!(windows) { + key.to_ascii_lowercase() + } else { + key + } +} + +fn project_config_for_lookup_key( + projects: &HashMap, + lookup_key: &str, +) -> Option { + if let Some(project_config) = projects.get(lookup_key) { + return Some(project_config.clone()); + } + + let mut normalized_matches: Vec<_> = projects + .iter() + .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key) + .collect(); + normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches + .first() + .map(|(_, project_config)| (**project_config).clone()) } pub fn validate_reserved_model_provider_ids( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6e721b1238..bc65aac61c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4,6 +4,7 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; use crate::config_loader::RequirementSource; +use crate::config_loader::project_trust_key; use crate::plugins::PluginsManager; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; @@ -5449,7 +5450,9 @@ model = "foo""#; // Since we created the [projects] table as part of migration, it is kept implicit. // Expect explicit per-project tables, preserving prior entries and appending the new one. - let expected = r#"toplevel = "baz" + let new_project_key = project_trust_key(new_project); + let expected = format!( + r#"toplevel = "baz" model = "foo" [projects."/Users/mbolin/code/codex4"] @@ -5459,14 +5462,42 @@ foo = "bar" [projects."/Users/mbolin/code/codex3"] trust_level = "trusted" -[projects."/Users/mbolin/code/codex2"] +[projects."{new_project_key}"] trust_level = "trusted" -"#; +"# + ); assert_eq!(contents, expected); Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> anyhow::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let alias_root = tmp.path().join("project_alias"); + std::fs::create_dir_all(&project_root)?; + std::os::unix::fs::symlink(&project_root, &alias_root)?; + + let config = ConfigToml { + projects: Some(HashMap::from([( + alias_root.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }; + + assert_eq!( + config.get_active_project(&project_root, /*repo_root*/ None), + None + ); + + Ok(()) +} + #[test] fn test_set_default_oss_provider() -> std::io::Result<()> { let temp_dir = TempDir::new()?; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9f9d83866d..9eaeb7149c 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -561,7 +561,9 @@ async fn load_requirements_from_legacy_scheme( struct ProjectTrustContext { project_root: AbsolutePathBuf, project_root_key: String, + project_root_lookup_keys: Vec, repo_root_key: Option, + repo_root_lookup_keys: Option>, projects_trust: std::collections::HashMap, user_config_file: AbsolutePathBuf, } @@ -584,28 +586,39 @@ impl ProjectTrustDecision { impl ProjectTrustContext { fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { - let dir_key = project_trust_key(dir.as_path()); - if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() { - return ProjectTrustDecision { - trust_level: Some(trust_level), - trust_key: dir_key, - }; + for dir_key in normalized_project_trust_keys(dir.as_path()) { + if let Some((trust_key, trust_level)) = + project_trust_for_lookup_key(&self.projects_trust, &dir_key) + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key, + }; + } } - if let Some(trust_level) = self.projects_trust.get(&self.project_root_key).copied() { - return ProjectTrustDecision { - trust_level: Some(trust_level), - trust_key: self.project_root_key.clone(), - }; + for project_root_key in &self.project_root_lookup_keys { + if let Some((trust_key, trust_level)) = + project_trust_for_lookup_key(&self.projects_trust, project_root_key) + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key, + }; + } } - if let Some(repo_root_key) = self.repo_root_key.as_ref() - && let Some(trust_level) = self.projects_trust.get(repo_root_key).copied() - { - return ProjectTrustDecision { - trust_level: Some(trust_level), - trust_key: repo_root_key.clone(), - }; + if let Some(repo_root_lookup_keys) = self.repo_root_lookup_keys.as_ref() { + for repo_root_key in repo_root_lookup_keys { + if let Some((trust_key, trust_level)) = + project_trust_for_lookup_key(&self.projects_trust, repo_root_key) + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key, + }; + } + } } ProjectTrustDecision { @@ -617,37 +630,35 @@ impl ProjectTrustContext { } } - fn disabled_reason_for_dir(&self, dir: &AbsolutePathBuf) -> Option { - let decision = self.decision_for_dir(dir); + fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option { if decision.is_trusted() { return None; } + let gated_features = "project-local config, hooks, and exec policies"; let trust_key = decision.trust_key.as_str(); let user_config_file = self.user_config_file.as_path().display(); match decision.trust_level { Some(TrustLevel::Untrusted) => Some(format!( - "{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted." + "{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted." )), _ => Some(format!( - "To load config.toml, add {trust_key} as a trusted project in {user_config_file}." + "To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}." )), } } } fn project_layer_entry( - trust_context: &ProjectTrustContext, dot_codex_folder: &AbsolutePathBuf, - layer_dir: &AbsolutePathBuf, config: TomlValue, - config_toml_exists: bool, + disabled_reason: Option, ) -> ConfigLayerEntry { let source = ConfigLayerSource::Project { dot_codex_folder: dot_codex_folder.clone(), }; - if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) { + if let Some(reason) = disabled_reason { ConfigLayerEntry::new_disabled(source, config, reason) } else { ConfigLayerEntry::new(source, config) @@ -673,25 +684,30 @@ async fn project_trust_context( let project_root = find_project_root(fs, cwd, project_root_markers).await?; let projects = project_trust_config.projects.unwrap_or_default(); - let project_root_key = project_trust_key(project_root.as_path()); + let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path()); + let project_root_key = project_root_lookup_keys + .first() + .cloned() + .unwrap_or_else(|| project_trust_key(project_root.as_path())); let repo_root = resolve_root_git_project_for_trust(fs, cwd).await; - let repo_root_key = repo_root + let repo_root_lookup_keys = repo_root .as_ref() - .map(|root| project_trust_key(root.as_path())); + .map(|root| normalized_project_trust_keys(root.as_path())); + let repo_root_key = repo_root_lookup_keys + .as_ref() + .and_then(|keys| keys.first().cloned()); let projects_trust = projects .into_iter() - .filter_map(|(key, project)| { - project - .trust_level - .map(|trust_level| (project_trust_key(Path::new(&key)), trust_level)) - }) + .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) .collect(); Ok(ProjectTrustContext { project_root, project_root_key, + project_root_lookup_keys, repo_root_key, + repo_root_lookup_keys, projects_trust, user_config_file: user_config_file.clone(), }) @@ -700,13 +716,52 @@ async fn project_trust_context( /// Canonicalize the path and convert it to a string to be used as a key in the /// projects trust map. On Windows, strips UNC, when possible, to try to ensure /// that different paths that point to the same location have the same key. -pub fn project_trust_key(project_path: &Path) -> String { - normalize_path(project_path) - .unwrap_or_else(|_| project_path.to_path_buf()) - .to_string_lossy() - .to_string() +pub fn project_trust_key(path: &Path) -> String { + normalized_project_trust_keys(path) + .into_iter() + .next() + .unwrap_or_else(|| normalize_project_trust_lookup_key(path.to_string_lossy().to_string())) } +fn normalized_project_trust_keys(path: &Path) -> Vec { + let normalized_path = normalize_project_trust_lookup_key(path.to_string_lossy().to_string()); + let normalized_canonical_path = normalize_project_trust_lookup_key( + normalize_path(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(), + ); + if normalized_path == normalized_canonical_path { + vec![normalized_canonical_path] + } else { + vec![normalized_canonical_path, normalized_path] + } +} + +fn normalize_project_trust_lookup_key(key: String) -> String { + if cfg!(windows) { + key.to_ascii_lowercase() + } else { + key + } +} +fn project_trust_for_lookup_key( + projects_trust: &std::collections::HashMap, + lookup_key: &str, +) -> Option<(String, TrustLevel)> { + if let Some(trust_level) = projects_trust.get(lookup_key).copied() { + return Some((lookup_key.to_string(), trust_level)); + } + + let mut normalized_matches: Vec<_> = projects_trust + .iter() + .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key) + .collect(); + normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches + .first() + .map(|(key, trust_level)| ((**key).clone(), **trust_level)) +} /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. @@ -834,6 +889,7 @@ async fn load_project_layers( } let decision = trust_context.decision_for_dir(&dir); + let disabled_reason = trust_context.disabled_reason_for_decision(&decision); let dot_codex_normalized = normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf()); if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { @@ -855,24 +911,16 @@ async fn load_project_layers( )); } layers.push(project_layer_entry( - trust_context, &dot_codex_abs, - &dir, TomlValue::Table(toml::map::Map::new()), - /*config_toml_exists*/ true, + disabled_reason.clone(), )); continue; } }; let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; - let entry = project_layer_entry( - trust_context, - &dot_codex_abs, - &dir, - config, - /*config_toml_exists*/ true, - ); + let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone()); layers.push(entry); } Err(err) => { @@ -881,11 +929,9 @@ async fn load_project_layers( // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. layers.push(project_layer_entry( - trust_context, &dot_codex_abs, - &dir, TomlValue::Table(toml::map::Map::new()), - /*config_toml_exists*/ false, + disabled_reason, )); } else { let config_file_display = config_file.as_path().display(); @@ -900,7 +946,6 @@ async fn load_project_layers( Ok(layers) } - /// The legacy mechanism for specifying admin-enforced configuration is to read /// from a file like `/etc/codex/managed_config.toml` that has the same /// structure as `config.toml` where fields like `approval_policy` can specify diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index eb533c4ca9..a8f4eda8ec 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -1343,6 +1343,66 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let alias_root = tmp.path().join("project_alias"); + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + tokio::fs::write( + project_root.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"project\"\n", + ) + .await?; + std::os::unix::fs::symlink(&project_root, &alias_root)?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + alias_root.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }) + .expect("serialize config"), + ) + .await?; + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(AbsolutePathBuf::from_absolute_path(&project_root)?), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await?; + + let project_layers: Vec<_> = layers + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 1); + assert!( + project_layers[0].disabled_reason.is_some(), + "configured aliases must not collapse into the canonical project key" + ); + assert_eq!(layers.effective_config().get("foo"), None); + + Ok(()) +} + #[tokio::test] async fn cli_override_can_update_project_local_mcp_server_when_project_is_trusted() -> std::io::Result<()> { @@ -1507,6 +1567,71 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Ok(()) } +#[tokio::test] +async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown() +-> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cases = [ + ("untrusted", Some(TrustLevel::Untrusted), true), + ("unknown", None, true), + ("trusted", Some(TrustLevel::Trusted), false), + ]; + + for (name, trust_level, expect_disabled) in cases { + let codex_home = tmp.path().join(format!("home_no_config_{name}")); + tokio::fs::create_dir_all(&codex_home).await?; + if let Some(trust_level) = trust_level { + make_config_for_test( + &codex_home, + &project_root, + trust_level, + /*project_root_markers*/ None, + ) + .await?; + } + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await?; + let project_layers: Vec<_> = layers + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!( + project_layers.len(), + 1, + "expected one project layer for {name}" + ); + assert_eq!( + project_layers[0].disabled_reason.is_some(), + expect_disabled, + "unexpected disabled state for {name}", + ); + assert_eq!( + project_layers[0].config, + TomlValue::Table(toml::map::Map::new()) + ); + } + + Ok(()) +} + #[tokio::test] async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> { let tmp = tempdir()?; diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 798bacfbf3..8f0f076f08 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -495,6 +495,8 @@ async fn load_exec_policy_with_warning( } pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result { + // Disabled project layers already represent the trust decision, so hooks + // and exec-policy loading can reuse the normal trusted-layer view. // Iterate the layers in increasing order of precedence, adding the *.rules // from each layer, so that higher-precedence layers can override // rules defined in lower-precedence ones. diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index f17869205d..64deca6bc6 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -10,6 +10,9 @@ use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use codex_app_server_protocol::ConfigLayerSource; use codex_config::RequirementsExecPolicy; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; +use codex_protocol::config_types::TrustLevel; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -67,6 +70,33 @@ fn starlark_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } +async fn write_project_trust_config( + codex_home: &Path, + trusted_projects: &[(&Path, TrustLevel)], +) -> std::io::Result<()> { + tokio::fs::write( + codex_home.join(codex_config::CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some( + trusted_projects + .iter() + .map(|(project, trust_level)| { + ( + project.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(*trust_level), + }, + ) + }) + .collect::>(), + ), + ..Default::default() + }) + .expect("serialize config"), + ) + .await +} + fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1756,3 +1786,159 @@ async fn assert_exec_approval_requirement_for_command( assert_eq!(requirement, expected_requirement); } + +#[tokio::test] +async fn exec_policies_only_load_from_trusted_project_layers() -> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let codex_home = temp.path().join("home_execpolicy_nested"); + let project_root = temp.path().join("project_execpolicy_nested"); + let nested = project_root.join("nested"); + let root_rules = project_root.join(".codex").join(RULES_DIR_NAME); + let nested_rules = nested.join(".codex").join(RULES_DIR_NAME); + + fs::create_dir_all(&codex_home)?; + fs::create_dir_all(&nested_rules)?; + fs::write(project_root.join(".git"), "gitdir: here")?; + fs::create_dir_all(&root_rules)?; + fs::write( + root_rules.join("deny-rm.rules"), + r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, + )?; + fs::write( + nested_rules.join("deny-mv.rules"), + r#"prefix_rule(pattern=["mv"], decision="forbidden")"#, + )?; + write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(nested)) + .build() + .await?; + + let policy = load_exec_policy(&config.config_layer_stack) + .await + .map_err(std::io::Error::other)?; + assert_eq!( + policy + .check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow) + .decision, + Decision::Allow, + ); + assert_eq!( + policy + .check_multiple([vec!["mv".to_string()]].iter(), &|_| Decision::Allow) + .decision, + Decision::Forbidden, + ); + + Ok(()) +} + +#[tokio::test] +async fn exec_policies_require_project_trust_without_config_toml() -> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let project_root = temp.path().join("project_execpolicy"); + let nested = project_root.join("nested"); + let rules_dir = project_root.join(".codex").join(RULES_DIR_NAME); + fs::create_dir_all(&nested)?; + fs::write(project_root.join(".git"), "gitdir: here")?; + fs::create_dir_all(&rules_dir)?; + fs::write( + rules_dir.join("deny-rm.rules"), + r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, + )?; + + let cases = [ + ( + "unknown", + Vec::<(&Path, TrustLevel)>::new(), + Decision::Allow, + ), + ( + "untrusted", + vec![(&project_root as &Path, TrustLevel::Untrusted)], + Decision::Allow, + ), + ( + "trusted", + vec![(&project_root as &Path, TrustLevel::Trusted)], + Decision::Forbidden, + ), + ]; + + for (name, trust_entries, expected_decision) in cases { + let codex_home = temp.path().join(format!("home_execpolicy_{name}")); + fs::create_dir_all(&codex_home)?; + write_project_trust_config(&codex_home, &trust_entries).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(nested.clone())) + .build() + .await?; + + let policy = load_exec_policy(&config.config_layer_stack) + .await + .map_err(std::io::Error::other)?; + assert_eq!( + policy + .check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow) + .decision, + expected_decision, + "unexpected execpolicy decision for {name}", + ); + } + + Ok(()) +} + +#[tokio::test] +async fn exec_policy_warnings_ignore_untrusted_project_rules_without_config_toml() +-> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let project_root = temp.path().join("project_execpolicy_warning"); + let nested = project_root.join("nested"); + let rules_dir = project_root.join(".codex").join(RULES_DIR_NAME); + fs::create_dir_all(&nested)?; + fs::write(project_root.join(".git"), "gitdir: here")?; + fs::create_dir_all(&rules_dir)?; + fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?; + + let cases = [ + ("unknown", Vec::<(&Path, TrustLevel)>::new(), false), + ( + "untrusted", + vec![(&project_root as &Path, TrustLevel::Untrusted)], + false, + ), + ( + "trusted", + vec![(&project_root as &Path, TrustLevel::Trusted)], + true, + ), + ]; + + for (name, trust_entries, expect_warning) in cases { + let codex_home = temp.path().join(format!("home_execpolicy_warning_{name}")); + fs::create_dir_all(&codex_home)?; + write_project_trust_config(&codex_home, &trust_entries).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(nested.clone())) + .build() + .await?; + + let warning = check_execpolicy_for_warnings(&config.config_layer_stack) + .await + .map_err(std::io::Error::other)?; + assert_eq!( + matches!(warning, Some(ExecPolicyError::ParsePolicy { .. })), + expect_warning, + "unexpected execpolicy warning state for {name}", + ); + } + + Ok(()) +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index abe96c5490..60183e986c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8,6 +8,7 @@ use crate::config_loader::NetworkDomainPermissionToml; use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; +use crate::config_loader::project_trust_key; use crate::exec::ExecCapturePolicy; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; @@ -21,6 +22,7 @@ use codex_models_manager::bundled_models_response; use codex_models_manager::model_info; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; @@ -53,6 +55,8 @@ use crate::tools::registry::ToolHandler; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AppInfo; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; use codex_execpolicy::Decision; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; @@ -303,6 +307,75 @@ fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> { .collect() } +fn write_project_hooks(dot_codex: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dot_codex)?; + std::fs::write( + dot_codex.join("hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo hello from hook" + } + ] + } + ] + } +}"#, + ) +} + +async fn write_project_trust_config( + codex_home: &Path, + trusted_projects: &[(&Path, TrustLevel)], +) -> std::io::Result<()> { + tokio::fs::write( + codex_home.join(codex_config::CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some( + trusted_projects + .iter() + .map(|(project, trust_level)| { + ( + project_trust_key(project), + ProjectConfig { + trust_level: Some(*trust_level), + }, + ) + }) + .collect::>(), + ), + ..Default::default() + }) + .expect("serialize config"), + ) + .await +} + +async fn preview_session_start_hooks( + config: &crate::config::Config, +) -> std::io::Result> { + let hooks = Hooks::new(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(config.config_layer_stack.clone()), + ..HooksConfig::default() + }); + + Ok( + hooks.preview_session_start(&codex_hooks::SessionStartRequest { + session_id: ThreadId::new(), + cwd: config.cwd.clone(), + transcript_path: None, + model: "gpt-5".to_string(), + permission_mode: "default".to_string(), + source: codex_hooks::SessionStartSource::Startup, + }), + ) +} + fn test_tool_runtime(session: Arc, turn_context: Arc) -> ToolCallRuntime { let router = Arc::new(ToolRouter::from_config( &turn_context.tools_config, @@ -6044,3 +6117,85 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() pretty_assertions::assert_eq!(output, expected); } + +#[tokio::test] +async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let codex_home = temp.path().join("home"); + let project_root = temp.path().join("project"); + let nested = project_root.join("nested"); + let root_dot_codex = project_root.join(".codex"); + let nested_dot_codex = nested.join(".codex"); + + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&nested_dot_codex)?; + std::fs::write(project_root.join(".git"), "gitdir: here")?; + write_project_hooks(&root_dot_codex)?; + write_project_hooks(&nested_dot_codex)?; + write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(nested)) + .build() + .await?; + + let preview = preview_session_start_hooks(&config).await?; + let expected_source_path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + nested_dot_codex.join("hooks.json"), + )?; + assert_eq!( + preview + .iter() + .map(|run| &run.source_path) + .collect::>(), + vec![&expected_source_path], + ); + + Ok(()) +} + +#[tokio::test] +async fn session_start_hooks_require_project_trust_without_config_toml() -> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let project_root = temp.path().join("project"); + let nested = project_root.join("nested"); + let dot_codex = project_root.join(".codex"); + std::fs::create_dir_all(&nested)?; + std::fs::write(project_root.join(".git"), "gitdir: here")?; + write_project_hooks(&dot_codex)?; + + let cases = [ + ("unknown", Vec::<(&Path, TrustLevel)>::new(), 0_usize), + ( + "untrusted", + vec![(&project_root as &Path, TrustLevel::Untrusted)], + 0_usize, + ), + ( + "trusted", + vec![(&project_root as &Path, TrustLevel::Trusted)], + 1_usize, + ), + ]; + + for (name, trust_entries, expected_hooks) in cases { + let codex_home = temp.path().join(format!("home_{name}")); + std::fs::create_dir_all(&codex_home)?; + write_project_trust_config(&codex_home, &trust_entries).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(nested.clone())) + .build() + .await?; + + assert_eq!( + preview_session_start_hooks(&config).await?.len(), + expected_hooks, + "unexpected hook count for {name}", + ); + } + + Ok(()) +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5a380bd3f7..ddffc46d0a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -482,16 +482,12 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else { continue; }; - if layer.disabled_reason.is_none() { + let Some(disabled_reason) = &layer.disabled_reason else { continue; - } + }; disabled_folders.push(( dot_codex_folder.as_path().display().to_string(), - layer - .disabled_reason - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "config.toml is disabled.".to_string()), + disabled_reason.clone(), )); } @@ -500,8 +496,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) } let mut message = concat!( - "Project config.toml files are disabled in the following folders. ", - "Settings in those files are ignored, but skills and exec policies still load.\n", + "Project-local config, hooks, and exec policies are disabled in the following folders ", + "until the project is trusted, but skills still load.\n", ) .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap index 3c8c248c36..0ffd8d35ca 100644 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap @@ -5,7 +5,9 @@ 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. + 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 diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index e9f2cd58bd..f04e9f9af9 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -53,10 +53,15 @@ impl WidgetRef for &TrustDirectoryWidget { column.push( Paragraph::new( - "Do you trust the contents of this directory? Working with untrusted contents comes with higher risk of prompt injection.".to_string(), + "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." + .to_string(), ) - .wrap(Wrap { trim: true }) - .inset(Insets::tlbr(/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0)), + .wrap(Wrap { trim: true }) + .inset(Insets::tlbr( + /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, + )), ); column.push(""); From 93ff798e5b29acdf659935cac6701f8e7dfe0de1 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Fri, 17 Apr 2026 17:58:32 -0700 Subject: [PATCH 08/18] [TUI] add external config migration prompt when start TUI (#17891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add a TUI startup migration prompt for external agent config - support migrating external configs including config, skills, AGENTS.md and plugins - gate the prompt behind features.external_migrate (default false) Screenshot 2026-04-14 at 9 29 14 PM Screenshot 2026-04-14 at 9 29 26 PM --------- Co-authored-by: Eric Traut --- codex-rs/config/src/types.rs | 19 + codex-rs/core/config.schema.json | 57 +- codex-rs/core/src/config/edit.rs | 77 ++ codex-rs/core/src/config/edit_tests.rs | 124 ++ codex-rs/core/src/external_agent_config.rs | 4 +- .../core/src/external_agent_config_tests.rs | 22 +- codex-rs/features/src/lib.rs | 12 + codex-rs/features/src/tests.rs | 17 + codex-rs/tui/src/app.rs | 38 + codex-rs/tui/src/app_server_session.rs | 30 + .../src/external_agent_config_migration.rs | 1024 +++++++++++++++++ ...external_agent_config_migration_startup.rs | 567 +++++++++ codex-rs/tui/src/lib.rs | 3 + ...xternal_agent_config_migration_prompt.snap | 25 + 14 files changed, 2004 insertions(+), 15 deletions(-) create mode 100644 codex-rs/tui/src/external_agent_config_migration.rs create mode 100644 codex-rs/tui/src/external_agent_config_migration_startup.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 243bc0d6d6..ae62216d25 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -592,7 +592,23 @@ const fn default_true() -> bool { /// Settings for notices we display to users via the tui and app-server clients /// (primarily the Codex IDE extension). NOTE: these are different from /// notifications - notices are warnings, NUX screens, acknowledgements, etc. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExternalConfigMigrationPrompts { + /// Tracks whether home-level external config migration prompts are hidden. + pub home: Option, + /// Tracks the last time the home-level external config migration prompt was shown. + pub home_last_prompted_at: Option, + /// Tracks which project paths have opted out of external config migration prompts. + #[serde(default)] + pub projects: BTreeMap, + /// Tracks the last time a project-level external config migration prompt was shown. + #[serde(default)] + pub project_last_prompted_at: BTreeMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. pub hide_full_access_warning: Option, @@ -608,6 +624,9 @@ pub struct Notice { /// Tracks acknowledged model migrations as old->new model slug mappings. #[serde(default)] pub model_migrations: BTreeMap, + /// Tracks scopes where external config migration prompts should be suppressed. + #[serde(default)] + pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } pub use crate::skills_config::BundledSkillsConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f0fbe89948..5135e4b0db 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -392,6 +392,9 @@ "experimental_windows_sandbox": { "type": "boolean" }, + "external_migration": { + "type": "boolean" + }, "fast_mode": { "type": "boolean" }, @@ -636,6 +639,39 @@ }, "type": "object" }, + "ExternalConfigMigrationPrompts": { + "additionalProperties": false, + "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", + "properties": { + "home": { + "description": "Tracks whether home-level external config migration prompts are hidden.", + "type": "boolean" + }, + "home_last_prompted_at": { + "description": "Tracks the last time the home-level external config migration prompt was shown.", + "format": "int64", + "type": "integer" + }, + "project_last_prompted_at": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "default": {}, + "description": "Tracks the last time a project-level external config migration prompt was shown.", + "type": "object" + }, + "projects": { + "additionalProperties": { + "type": "boolean" + }, + "default": {}, + "description": "Tracks which project paths have opted out of external config migration prompts.", + "type": "object" + } + }, + "type": "object" + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -1153,8 +1189,22 @@ "type": "object" }, "Notice": { - "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", + "additionalProperties": false, "properties": { + "external_config_migration_prompts": { + "allOf": [ + { + "$ref": "#/definitions/ExternalConfigMigrationPrompts" + } + ], + "default": { + "home": null, + "home_last_prompted_at": null, + "project_last_prompted_at": {}, + "projects": {} + }, + "description": "Tracks scopes where external config migration prompts should be suppressed." + }, "hide_full_access_warning": { "description": "Tracks whether the user has acknowledged the full access warning prompt.", "type": "boolean" @@ -2288,6 +2338,9 @@ "experimental_windows_sandbox": { "type": "boolean" }, + "external_migration": { + "type": "boolean" + }, "fast_mode": { "type": "boolean" }, @@ -2866,4 +2919,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 5adc0f998c..8f336f7ff5 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -43,6 +43,14 @@ pub enum ConfigEdit { SetWindowsWslSetupAcknowledged(bool), /// Toggle the model migration prompt acknowledgement flag. SetNoticeHideModelMigrationPrompt(String, bool), + /// Toggle the home external config migration prompt acknowledgement flag. + SetNoticeHideExternalConfigMigrationPromptHome(bool), + /// Record when the home external config migration prompt was last shown. + SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64), + /// Toggle the project external config migration prompt acknowledgement flag. + SetNoticeHideExternalConfigMigrationPromptProject(String, bool), + /// Record when the project external config migration prompt was last shown. + SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64), /// Record that a migration prompt was shown for an old->new model mapping. RecordModelMigrationSeen { from: String, to: String }, /// Replace the entire `[mcp_servers]` table. @@ -421,6 +429,53 @@ impl ConfigDocument { value(*acknowledged), )) } + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self + .write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "home", + ], + value(*acknowledged), + )), + ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => { + Ok(self.write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "home_last_prompted_at", + ], + value(*timestamp), + )) + } + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + project, + acknowledged, + ) => Ok(self.write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "projects", + project.as_str(), + ], + value(*acknowledged), + )), + ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( + project, + timestamp, + ) => Ok(self.write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "project_last_prompted_at", + project.as_str(), + ], + value(*timestamp), + )), ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( Scope::Global, &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], @@ -919,6 +974,28 @@ impl ConfigEditsBuilder { self } + pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( + acknowledged, + )); + self + } + + pub fn set_hide_external_config_migration_prompt_project( + mut self, + project: &str, + acknowledged: bool, + ) -> Self { + self.edits.push( + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + project.to_string(), + acknowledged, + ), + ); + self + } + pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self { self.edits.push(ConfigEdit::RecordModelMigrationSeen { from: from.to_string(), diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 4f340d89b3..84a4a34ea6 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -552,6 +552,130 @@ gpt-5 = "gpt-5.1" assert_eq!(contents, expected); } +#[test] +fn blocking_set_hide_external_config_migration_prompt_home_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( + true, + )], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" + +[notice.external_config_migration_prompts] +home = true +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_hide_external_config_migration_prompt_project_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + "/Users/alexsong/code/skills".to_string(), + true, + ), + ], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" + +[notice.external_config_migration_prompts.projects] +"/Users/alexsong/code/skills" = true +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_external_config_migration_prompt_home_last_prompted_at_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" + +[notice.external_config_migration_prompts] +home_last_prompted_at = 1760000000 +"#; + assert_eq!(contents, expected); +} + +#[test] +fn blocking_set_external_config_migration_prompt_project_last_prompted_at_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ + ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( + "/Users/alexsong/code/skills".to_string(), + 1_760_000_000, + ), + ], + ) + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" + +[notice.external_config_migration_prompts.project_last_prompted_at] +"/Users/alexsong/code/skills" = 1760000000 +"#; + assert_eq!(contents, expected); +} + #[test] fn blocking_replace_mcp_servers_round_trips() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 73aba5af13..db5894e952 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -273,7 +273,7 @@ impl ExternalAgentConfigService { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}", + "Migrate {} to {}", source_agents_md.display(), target_agents_md.display() ), @@ -357,7 +357,7 @@ impl ExternalAgentConfigService { Some(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: format!("Import enabled plugins from {}", source_settings.display()), + description: format!("Migrate enabled plugins from {}", source_settings.display()), cwd, details: Some(plugin_details), }) diff --git a/codex-rs/core/src/external_agent_config_tests.rs b/codex-rs/core/src/external_agent_config_tests.rs index 4fd81351a9..3b427f6bf0 100644 --- a/codex-rs/core/src/external_agent_config_tests.rs +++ b/codex-rs/core/src/external_agent_config_tests.rs @@ -74,7 +74,7 @@ async fn detect_home_lists_config_skills_and_agents_md() { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}", + "Migrate {} to {}", external_agent_home.join("CLAUDE.md").display(), codex_home.join("AGENTS.md").display() ), @@ -107,7 +107,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}", + "Migrate {} to {}", repo_root.join("CLAUDE.md").display(), repo_root.join("AGENTS.md").display(), ), @@ -117,7 +117,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}", + "Migrate {} to {}", repo_root.join("CLAUDE.md").display(), repo_root.join("AGENTS.md").display(), ), @@ -194,7 +194,7 @@ async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid( ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}", + "Migrate {} to {}", repo_root.join(".claude").join("CLAUDE.md").display(), repo_root.join("AGENTS.md").display(), ), @@ -566,7 +566,7 @@ async fn detect_repo_prefers_non_empty_external_agent_agents_source() { vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}", + "Migrate {} to {}", repo_root.join(".claude").join("CLAUDE.md").display(), repo_root.join("AGENTS.md").display(), ), @@ -650,7 +650,7 @@ async fn detect_home_lists_enabled_plugins_from_settings() { vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( - "Import enabled plugins from {}", + "Migrate enabled plugins from {}", external_agent_home.join("settings.json").display() ), cwd: None, @@ -710,7 +710,7 @@ enabled = true vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( - "Import enabled plugins from {}", + "Migrate enabled plugins from {}", repo_root.join(".claude").join("settings.json").display() ), cwd: Some(repo_root), @@ -868,7 +868,7 @@ enabled = true vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( - "Import enabled plugins from {}", + "Migrate enabled plugins from {}", repo_root.join(".claude").join("settings.json").display() ), cwd: Some(repo_root), @@ -1048,7 +1048,7 @@ source = "owner/debug-marketplace" vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( - "Import enabled plugins from {}", + "Migrate enabled plugins from {}", repo_root.join(".claude").join("settings.json").display() ), cwd: Some(repo_root), @@ -1275,7 +1275,7 @@ async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( - "Import enabled plugins from {}", + "Migrate enabled plugins from {}", external_agent_home.join("settings.json").display() ), cwd: None, @@ -1426,7 +1426,7 @@ async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( - "Import enabled plugins from {}", + "Migrate enabled plugins from {}", repo_root.join(".claude").join("settings.json").display() ), cwd: Some(repo_root), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 9a8862c277..f811f6aa1e 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -156,6 +156,8 @@ pub enum Feature { ToolSuggest, /// Enable plugins. Plugins, + /// Show the startup prompt for migrating external agent config into Codex. + ExternalMigration, /// Allow the model to invoke the built-in image generation tool. ImageGeneration, /// Allow prompting and installing missing MCP dependencies. @@ -844,6 +846,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ExternalMigration, + key: "external_migration", + stage: Stage::Experimental { + name: "External migration", + menu_description: "Show a startup prompt when Codex detects migratable external agent config for this machine or project.", + announcement: "", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::ImageGeneration, key: "image_generation", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a561d0ee3f..18b030f88c 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -103,6 +103,23 @@ fn guardian_approval_is_experimental_and_user_toggleable() { assert_eq!(Feature::GuardianApproval.default_enabled(), false); } +#[test] +fn external_migration_is_experimental_and_disabled_by_default() { + let spec = Feature::ExternalMigration.info(); + let stage = spec.stage; + + assert!(matches!(stage, Stage::Experimental { .. })); + assert_eq!(stage.experimental_menu_name(), Some("External migration")); + assert_eq!( + stage.experimental_menu_description(), + Some( + "Show a startup prompt when Codex detects migratable external agent config for this machine or project." + ) + ); + assert_eq!(stage.experimental_announcement(), None); + assert_eq!(Feature::ExternalMigration.default_enabled(), false); +} + #[test] fn request_permissions_is_under_development() { assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ddffc46d0a..925dba245b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -28,6 +28,8 @@ use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::split_command_string; use crate::exec_command::strip_bash_lc_and_escape; +use crate::external_agent_config_migration_startup::ExternalAgentConfigMigrationStartupOutcome; +use crate::external_agent_config_migration_startup::handle_external_agent_config_migration_prompt_if_needed; use crate::external_editor; use crate::file_search::FileSearchManager; use crate::history_cell; @@ -3861,6 +3863,7 @@ impl App { session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, + entered_trust_nux: bool, should_prompt_windows_sandbox_nux_at_startup: bool, remote_app_server_url: Option, remote_app_server_auth_token: Option, @@ -3878,6 +3881,38 @@ impl App { let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; + let external_agent_config_migration_outcome = + handle_external_agent_config_migration_prompt_if_needed( + tui, + &mut app_server, + &mut config, + &cli_kv_overrides, + &harness_overrides, + entered_trust_nux, + ) + .await?; + let external_agent_config_migration_message = match external_agent_config_migration_outcome + { + ExternalAgentConfigMigrationStartupOutcome::Continue { success_message } => { + success_message + } + ExternalAgentConfigMigrationStartupOutcome::ExitRequested => { + app_server + .shutdown() + .await + .inspect_err(|err| { + tracing::warn!("app-server shutdown failed: {err}"); + }) + .ok(); + return Ok(AppExitInfo { + token_usage: TokenUsage::default(), + thread_id: None, + thread_name: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }); + } + }; let bootstrap = app_server.bootstrap(&config).await?; let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; @@ -4048,6 +4083,9 @@ impl App { (ChatWidget::new_with_app_event(init), Some(forked)) } }; + if let Some(message) = external_agent_config_migration_message { + chat_widget.add_info_message(message, /*hint*/ None); + } chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 7db3dd7d39..d31d2e9732 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -14,6 +14,11 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExternalAgentConfigDetectParams; +use codex_app_server_protocol::ExternalAgentConfigDetectResponse; +use codex_app_server_protocol::ExternalAgentConfigImportParams; +use codex_app_server_protocol::ExternalAgentConfigImportResponse; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; @@ -286,6 +291,31 @@ impl AppServerSession { .wrap_err("account/read failed during TUI bootstrap") } + pub(crate) async fn external_agent_config_detect( + &mut self, + params: ExternalAgentConfigDetectParams, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params }) + .await + .wrap_err("externalAgentConfig/detect failed during TUI startup") + } + + pub(crate) async fn external_agent_config_import( + &mut self, + migration_items: Vec, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ExternalAgentConfigImport { + request_id, + params: ExternalAgentConfigImportParams { migration_items }, + }) + .await + .wrap_err("externalAgentConfig/import failed during TUI startup") + } + pub(crate) async fn next_event(&mut self) -> Option { self.client.next_event().await } diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs new file mode 100644 index 0000000000..2b667b802a --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -0,0 +1,1024 @@ +use crate::diff_render::display_path_for; +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::selection_list::selection_option_row_with_dim; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; +use codex_app_server_protocol::PluginsMigration; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::prelude::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use tokio_stream::StreamExt; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ExternalAgentConfigMigrationOutcome { + Proceed(Vec), + Skip, + SkipForever, + Exit, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FocusArea { + Items, + Actions, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActionMenuOption { + Proceed, + Skip, + SkipForever, +} + +impl ActionMenuOption { + fn label(self) -> &'static str { + match self { + Self::Proceed => "Proceed with selected", + Self::Skip => "Skip for now", + Self::SkipForever => "Don't ask again", + } + } + + fn previous(self) -> Option { + match self { + Self::Proceed => None, + Self::Skip => Some(Self::Proceed), + Self::SkipForever => Some(Self::Skip), + } + } + + fn next(self) -> Option { + match self { + Self::Proceed => Some(Self::Skip), + Self::Skip => Some(Self::SkipForever), + Self::SkipForever => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct MigrationSelection { + item: ExternalAgentConfigMigrationItem, + enabled: bool, +} + +struct RenderLineEntry { + item_idx: Option, + kind: RenderLineKind, + line: Line<'static>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RenderLineKind { + Section, + Item, + ItemDetail, +} + +pub(crate) async fn run_external_agent_config_migration_prompt( + tui: &mut Tui, + items: &[ExternalAgentConfigMigrationItem], + selected_items: &[ExternalAgentConfigMigrationItem], + error: Option<&str>, +) -> ExternalAgentConfigMigrationOutcome { + let mut screen = ExternalAgentConfigMigrationScreen::new( + tui.frame_requester(), + items, + selected_items, + error.map(str::to_owned), + ); + + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + }); + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + }); + } + } + } else { + screen.skip(); + break; + } + } + + screen.outcome() +} + +struct ExternalAgentConfigMigrationScreen { + request_frame: FrameRequester, + items: Vec, + selected_item_idx: Option, + scroll_top: usize, + focus: FocusArea, + highlighted_action: ActionMenuOption, + done: bool, + outcome: ExternalAgentConfigMigrationOutcome, + error: Option, +} + +impl ExternalAgentConfigMigrationScreen { + fn proceed_enabled(&self) -> bool { + self.selected_count() > 0 + } + + fn first_available_action(&self) -> ActionMenuOption { + if self.proceed_enabled() { + ActionMenuOption::Proceed + } else { + ActionMenuOption::Skip + } + } + + fn previous_available_action(&self, action: ActionMenuOption) -> Option { + let mut candidate = action.previous(); + while let Some(option) = candidate { + if option != ActionMenuOption::Proceed || self.proceed_enabled() { + return Some(option); + } + candidate = option.previous(); + } + None + } + + fn next_available_action(&self, action: ActionMenuOption) -> Option { + let mut candidate = action.next(); + while let Some(option) = candidate { + if option != ActionMenuOption::Proceed || self.proceed_enabled() { + return Some(option); + } + candidate = option.next(); + } + None + } + + fn normalize_highlighted_action(&mut self) { + if self.highlighted_action == ActionMenuOption::Proceed && !self.proceed_enabled() { + self.highlighted_action = self.first_available_action(); + } + } + + fn display_description(item: &ExternalAgentConfigMigrationItem) -> String { + let Some(cwd) = item.cwd.as_deref() else { + return item.description.clone(); + }; + + fn reformat_description( + description: &str, + prefix: &str, + separator: &str, + cwd: &std::path::Path, + ) -> Option { + let remainder = description.strip_prefix(prefix)?; + let (left, right) = remainder.split_once(separator)?; + Some(format!( + "{prefix}{}{}{}", + display_path_for(std::path::Path::new(left), cwd), + separator, + display_path_for(std::path::Path::new(right), cwd) + )) + } + + if let Some(reformatted) = + reformat_description(&item.description, "Migrate ", " into ", cwd) + { + return reformatted; + } + + if let Some(reformatted) = + reformat_description(&item.description, "Migrate skills from ", " to ", cwd) + { + return reformatted; + } + + if let Some(reformatted) = reformat_description(&item.description, "Migrate ", " to ", cwd) + { + return reformatted; + } + + if let Some(reformatted) = reformat_description(&item.description, "Import ", " to ", cwd) { + return reformatted; + } + + if let Some(source) = item + .description + .strip_prefix("Migrate enabled plugins from ") + { + let description = format!( + "Migrate enabled plugins from {}", + display_path_for(std::path::Path::new(source), cwd) + ); + if let Some(details) = &item.details { + let marketplace_count = details.plugins.len(); + let plugin_count = details + .plugins + .iter() + .map(|plugin_group| plugin_group.plugin_names.len()) + .sum::(); + return format!( + "{description} ({marketplace_count} {}, {plugin_count} {})", + if marketplace_count == 1 { + "marketplace" + } else { + "marketplaces" + }, + if plugin_count == 1 { + "plugin" + } else { + "plugins" + } + ); + } + return description; + } + + item.description.clone() + } + + fn new( + request_frame: FrameRequester, + items: &[ExternalAgentConfigMigrationItem], + selected_items: &[ExternalAgentConfigMigrationItem], + error: Option, + ) -> Self { + let items = items + .iter() + .cloned() + .map(|item| MigrationSelection { + enabled: selected_items.contains(&item), + item, + }) + .collect::>(); + let selected_item_idx = (!items.is_empty()).then_some(0); + Self { + request_frame, + items, + selected_item_idx, + scroll_top: 0, + focus: FocusArea::Items, + highlighted_action: ActionMenuOption::Proceed, + done: false, + outcome: ExternalAgentConfigMigrationOutcome::Skip, + error, + } + } + + fn plugin_detail_lines(plugin_groups: &[PluginsMigration]) -> Vec> { + let mut lines = plugin_groups + .iter() + .take(3) + .map(|plugin_group| { + let mut plugin_names = plugin_group + .plugin_names + .iter() + .take(2) + .cloned() + .collect::>(); + let hidden_plugin_count = plugin_group + .plugin_names + .len() + .saturating_sub(plugin_names.len()); + if hidden_plugin_count > 0 { + plugin_names.push(format!("+{hidden_plugin_count} more")); + } + Line::from(format!( + " • {}: {}", + plugin_group.marketplace_name, + plugin_names.join(", ") + )) + }) + .collect::>(); + let hidden_marketplace_count = plugin_groups.len().saturating_sub(lines.len()); + if hidden_marketplace_count > 0 { + lines.push(Line::from(format!( + " • +{hidden_marketplace_count} more marketplaces" + ))); + } + lines + } + + fn is_done(&self) -> bool { + self.done + } + + fn outcome(&self) -> ExternalAgentConfigMigrationOutcome { + self.outcome.clone() + } + + fn finish_with(&mut self, outcome: ExternalAgentConfigMigrationOutcome) { + self.outcome = outcome; + self.done = true; + self.request_frame.schedule_frame(); + } + + fn proceed(&mut self) { + let selected = self.selected_items(); + if selected.is_empty() { + self.error = Some("Select at least one item or choose a skip option.".to_string()); + self.request_frame.schedule_frame(); + return; + } + + self.finish_with(ExternalAgentConfigMigrationOutcome::Proceed(selected)); + } + + fn skip(&mut self) { + self.finish_with(ExternalAgentConfigMigrationOutcome::Skip); + } + + fn skip_forever(&mut self) { + self.finish_with(ExternalAgentConfigMigrationOutcome::SkipForever); + } + + fn exit(&mut self) { + self.finish_with(ExternalAgentConfigMigrationOutcome::Exit); + } + + fn selected_items(&self) -> Vec { + self.items + .iter() + .filter(|item| item.enabled) + .map(|item| item.item.clone()) + .collect() + } + + fn selected_count(&self) -> usize { + self.items.iter().filter(|item| item.enabled).count() + } + + fn set_all_enabled(&mut self, enabled: bool) { + for item in &mut self.items { + item.enabled = enabled; + } + self.error = None; + self.normalize_highlighted_action(); + self.request_frame.schedule_frame(); + } + + fn toggle_selected_item(&mut self) { + if self.focus != FocusArea::Items { + return; + } + let Some(selected_idx) = self.selected_item_idx else { + return; + }; + let Some(item) = self.items.get_mut(selected_idx) else { + return; + }; + + item.enabled = !item.enabled; + self.error = None; + self.normalize_highlighted_action(); + self.request_frame.schedule_frame(); + } + + fn move_up(&mut self) { + match self.focus { + FocusArea::Items => match self.selected_item_idx { + Some(0) => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::SkipForever; + } + Some(idx) => { + self.selected_item_idx = Some(idx.saturating_sub(1)); + } + None => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::SkipForever; + } + }, + FocusArea::Actions => { + if let Some(previous) = self.previous_available_action(self.highlighted_action) { + self.highlighted_action = previous; + } else { + self.focus = FocusArea::Items; + if !self.items.is_empty() { + self.selected_item_idx = Some(self.items.len() - 1); + } + } + } + } + self.ensure_selected_item_visible(); + self.request_frame.schedule_frame(); + } + + fn move_down(&mut self) { + match self.focus { + FocusArea::Items => match self.selected_item_idx { + Some(idx) if idx + 1 < self.items.len() => { + self.selected_item_idx = Some(idx + 1); + } + _ => { + self.focus = FocusArea::Actions; + self.highlighted_action = self.first_available_action(); + } + }, + FocusArea::Actions => { + if let Some(next) = self.next_available_action(self.highlighted_action) { + self.highlighted_action = next; + } else { + self.focus = FocusArea::Items; + if !self.items.is_empty() { + self.selected_item_idx = Some(0); + } + } + } + } + self.ensure_selected_item_visible(); + self.request_frame.schedule_frame(); + } + + fn confirm_selection(&mut self) { + match self.focus { + FocusArea::Items => self.toggle_selected_item(), + FocusArea::Actions => match self.highlighted_action { + ActionMenuOption::Proceed => self.proceed(), + ActionMenuOption::Skip => self.skip(), + ActionMenuOption::SkipForever => self.skip_forever(), + }, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if is_ctrl_exit_combo(key_event) { + self.exit(); + return; + } + + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.move_up(), + KeyCode::Down | KeyCode::Char('j') => self.move_down(), + KeyCode::Char('1') => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::Proceed; + self.proceed(); + } + KeyCode::Char('2') => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::Skip; + self.skip(); + } + KeyCode::Char('3') => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::SkipForever; + self.skip_forever(); + } + KeyCode::Char(' ') => self.toggle_selected_item(), + KeyCode::Char('a') => self.set_all_enabled(/*enabled*/ true), + KeyCode::Char('n') => self.set_all_enabled(/*enabled*/ false), + KeyCode::Enter => self.confirm_selection(), + KeyCode::Esc => self.skip(), + _ => {} + } + } + + fn ensure_selected_item_visible(&mut self) { + let Some(selected_idx) = self.selected_item_idx else { + self.scroll_top = 0; + return; + }; + let selected_render_idx = self.selected_render_line_index(selected_idx); + let visible_rows = self.render_line_count().max(1); + if selected_render_idx < self.scroll_top { + self.scroll_top = selected_render_idx; + } else { + let bottom = self.scroll_top + visible_rows.saturating_sub(1); + if selected_render_idx > bottom { + self.scroll_top = selected_render_idx + 1 - visible_rows; + } + } + } + + fn render_line_count(&self) -> usize { + self.build_render_lines().len() + } + + fn selected_render_line_index(&self, selected_item_idx: usize) -> usize { + self.build_render_lines() + .iter() + .position(|entry| entry.item_idx == Some(selected_item_idx)) + .unwrap_or(selected_item_idx) + } + + fn section_title(cwd: Option<&std::path::Path>) -> Line<'static> { + match cwd { + Some(cwd) => Line::from(vec!["Project: ".bold(), cwd.display().to_string().dim()]), + None => Line::from("Home".bold()), + } + } + + fn build_render_lines(&self) -> Vec { + let mut lines = Vec::new(); + let mut current_scope: Option> = None; + for (idx, item) in self.items.iter().enumerate() { + let scope = item.item.cwd.as_deref(); + if current_scope != Some(scope) { + if current_scope.is_some() { + lines.push(RenderLineEntry { + item_idx: None, + kind: RenderLineKind::Section, + line: Line::from(""), + }); + } + lines.push(RenderLineEntry { + item_idx: None, + kind: RenderLineKind::Section, + line: Self::section_title(scope), + }); + current_scope = Some(scope); + } + lines.push(RenderLineEntry { + item_idx: Some(idx), + kind: RenderLineKind::Item, + line: Line::from(format!( + " [{}] {}", + if item.enabled { "x" } else { " " }, + Self::display_description(&item.item) + )), + }); + if let Some(details) = &item.item.details { + for line in Self::plugin_detail_lines(&details.plugins) { + lines.push(RenderLineEntry { + item_idx: None, + kind: RenderLineKind::ItemDetail, + line, + }); + } + } + } + lines + } + + fn render_items(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let rows = self.build_render_lines(); + let visible_rows = area.height as usize; + let mut start_idx = self.scroll_top.min(rows.len().saturating_sub(1)); + if let Some(selected_item_idx) = self.selected_item_idx { + let selected_render_idx = self.selected_render_line_index(selected_item_idx); + if selected_render_idx < start_idx { + start_idx = selected_render_idx; + } else if visible_rows > 0 { + let bottom = start_idx + visible_rows - 1; + if selected_render_idx > bottom { + start_idx = selected_render_idx + 1 - visible_rows; + } + } + } + + let mut y = area.y; + for entry in rows.iter().skip(start_idx).take(visible_rows) { + if y >= area.y + area.height { + break; + } + + let selected = + self.focus == FocusArea::Items && self.selected_item_idx == entry.item_idx; + let mut line = entry.line.clone(); + if selected { + line.spans.iter_mut().for_each(|span| { + span.style = span.style.cyan().bold(); + }); + } else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() { + line.spans.iter_mut().for_each(|span| { + span.style = span.style.dim(); + }); + } + let line = truncate_line_with_ellipsis_if_overflow(line, area.width as usize); + line.render( + Rect { + x: area.x, + y, + width: area.width, + height: 1, + }, + buf, + ); + y = y.saturating_add(1); + } + } +} + +impl WidgetRef for &ExternalAgentConfigMigrationScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + + let inner_area = area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)); + let error_height = u16::from(self.error.is_some()); + let fixed_height = 1u16 + 2u16 + error_height + 1u16 + 4u16 + 1u16; + let list_height = + self.render_line_count() + .max(1) + .min(inner_area.height.saturating_sub(fixed_height) as usize) as u16; + let [ + header_area, + intro_area, + error_area, + list_area, + list_gap_area, + actions_area, + footer_area, + _spacer_area, + ] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(error_height), + Constraint::Length(list_height), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(inner_area); + + let heading = Line::from(vec!["> ".into(), "External agent config detected".bold()]); + heading.render(header_area, buf); + + Paragraph::new(vec![ + Line::from("We found settings from another agent that you can add to this project."), + Line::from("Select what to import"), + ]) + .wrap(Wrap { trim: false }) + .render(intro_area, buf); + + if let Some(error) = &self.error { + Paragraph::new(error.clone().red().to_string()) + .wrap(Wrap { trim: false }) + .render(error_area, buf); + } + + self.render_items(list_area, buf); + Clear.render(list_gap_area, buf); + + let [ + actions_intro_area, + proceed_area, + skip_area, + skip_forever_area, + ] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(actions_area); + let actions_intro = format!( + "Selected {} of {} item(s).", + self.selected_count(), + self.items.len() + ); + Paragraph::new(actions_intro) + .wrap(Wrap { trim: false }) + .render(actions_intro_area, buf); + selection_option_row_with_dim( + /*index*/ 0, + ActionMenuOption::Proceed.label().to_string(), + self.focus == FocusArea::Actions + && self.highlighted_action == ActionMenuOption::Proceed, + /*dim*/ self.focus != FocusArea::Actions || !self.proceed_enabled(), + ) + .render(proceed_area, buf); + selection_option_row_with_dim( + /*index*/ 1, + ActionMenuOption::Skip.label().to_string(), + self.focus == FocusArea::Actions && self.highlighted_action == ActionMenuOption::Skip, + /*dim*/ self.focus != FocusArea::Actions, + ) + .render(skip_area, buf); + selection_option_row_with_dim( + /*index*/ 2, + ActionMenuOption::SkipForever.label().to_string(), + self.focus == FocusArea::Actions + && self.highlighted_action == ActionMenuOption::SkipForever, + /*dim*/ self.focus != FocusArea::Actions, + ) + .render(skip_forever_area, buf); + + Line::from(vec![ + "Use ".dim(), + key_hint::plain(KeyCode::Up).into(), + "/".dim(), + key_hint::plain(KeyCode::Down).into(), + " to move, ".dim(), + key_hint::plain(KeyCode::Char(' ')).into(), + " to toggle, ".dim(), + "1".cyan(), + "/".dim(), + "2".cyan(), + "/".dim(), + "3".cyan(), + " to choose, ".dim(), + "a".cyan(), + "/".dim(), + "n".cyan(), + " for all/none".dim(), + ]) + .render(footer_area, buf); + } +} + +fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { + key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) +} + +#[cfg(test)] +mod tests { + use super::ActionMenuOption; + use super::ExternalAgentConfigMigrationOutcome; + use super::ExternalAgentConfigMigrationScreen; + use super::FocusArea; + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use crate::tui::FrameRequester; + use codex_app_server_protocol::ExternalAgentConfigMigrationItem; + use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; + use codex_app_server_protocol::PluginsMigration; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + use std::path::PathBuf; + + fn sample_items() -> Vec { + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: + "Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/config.toml" + .to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: + "Migrate enabled plugins from /workspace/project/.claude/settings.json" + .to_string(), + cwd: Some(PathBuf::from("/workspace/project")), + details: Some(codex_app_server_protocol::MigrationDetails { + plugins: vec![ + PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec![ + "deployer".to_string(), + "formatter".to_string(), + "lint".to_string(), + ], + }, + PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }, + PluginsMigration { + marketplace_name: "debug".to_string(), + plugin_names: vec!["sample".to_string()], + }, + PluginsMigration { + marketplace_name: "data-tools".to_string(), + plugin_names: vec!["warehouse".to_string()], + }, + ], + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: "Migrate /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md" + .to_string(), + cwd: Some(PathBuf::from("/workspace/project")), + details: None, + }, + ] + } + + fn render_screen( + screen: &ExternalAgentConfigMigrationScreen, + width: u16, + height: u16, + ) -> String { + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + { + let mut frame = terminal.get_frame(); + frame.render_widget_ref(screen, frame.area()); + } + terminal.flush().expect("flush"); + terminal.backend().to_string() + } + + #[test] + fn prompt_snapshot() { + let items = sample_items(); + let screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 21); + assert_snapshot!("external_agent_config_migration_prompt", rendered); + } + + #[test] + fn proceed_returns_selected_items() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(screen.is_done()); + assert_eq!( + screen.outcome(), + ExternalAgentConfigMigrationOutcome::Proceed(items) + ); + } + + #[test] + fn toggle_item_then_proceed_keeps_remaining_selection() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(screen.is_done()); + assert_eq!( + screen.outcome(), + ExternalAgentConfigMigrationOutcome::Proceed(vec![items[1].clone(), items[2].clone(),]) + ); + } + + #[test] + fn escape_skips_prompt() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(screen.is_done()); + assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::Skip); + } + + #[test] + fn skip_forever_returns_skip_forever_outcome() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.move_down(); + screen.move_down(); + screen.move_down(); + screen.move_down(); + screen.move_down(); + screen.confirm_selection(); + + assert_eq!( + screen.outcome(), + ExternalAgentConfigMigrationOutcome::SkipForever + ); + } + + #[test] + fn proceed_requires_at_least_one_selected_item() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + + assert!(!screen.is_done()); + assert_eq!(screen.highlighted_action, ActionMenuOption::Proceed); + let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 20); + assert!( + rendered.contains("Select at least one item or choose a skip option."), + "expected inline validation error, got:\n{rendered}" + ); + } + + #[test] + fn proceed_action_is_skipped_when_no_items_are_selected() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + assert_eq!(screen.focus, FocusArea::Actions); + assert_eq!(screen.highlighted_action, ActionMenuOption::Skip); + } + + #[test] + fn numeric_shortcuts_choose_actions() { + let items = sample_items(); + + let mut proceed_screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + proceed_screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!( + proceed_screen.outcome(), + ExternalAgentConfigMigrationOutcome::Proceed(items.clone()) + ); + + let mut skip_screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + skip_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!( + skip_screen.outcome(), + ExternalAgentConfigMigrationOutcome::Skip + ); + + let mut skip_forever_screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + skip_forever_screen.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); + assert_eq!( + skip_forever_screen.outcome(), + ExternalAgentConfigMigrationOutcome::SkipForever + ); + } +} diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs new file mode 100644 index 0000000000..93f2302535 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -0,0 +1,567 @@ +use crate::app_server_session::AppServerSession; +use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome; +use crate::external_agent_config_migration::run_external_agent_config_migration_prompt; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use crate::legacy_core::config::edit::ConfigEdit; +use crate::legacy_core::config::edit::ConfigEditsBuilder; +use crate::tui; +use codex_app_server_protocol::ExternalAgentConfigDetectParams; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; +use codex_features::Feature; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use std::collections::BTreeSet; +use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use toml::Value as TomlValue; + +const EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS: i64 = 5 * 24 * 60 * 60; + +pub(crate) enum ExternalAgentConfigMigrationStartupOutcome { + Continue { success_message: Option }, + ExitRequested, +} + +fn should_show_external_agent_config_migration_prompt( + config: &Config, + entered_trust_nux: bool, +) -> bool { + entered_trust_nux && config.features.enabled(Feature::ExternalMigration) +} + +fn external_config_migration_project_key(path: &Path) -> String { + path.display().to_string() +} + +fn is_external_config_migration_scope_hidden(config: &Config, cwd: Option<&Path>) -> bool { + match cwd { + Some(cwd) => config + .notices + .external_config_migration_prompts + .projects + .get(&external_config_migration_project_key(cwd)) + .copied() + .unwrap_or(false), + None => config + .notices + .external_config_migration_prompts + .home + .unwrap_or(false), + } +} + +fn external_config_migration_last_prompted_at(config: &Config, cwd: Option<&Path>) -> Option { + match cwd { + Some(cwd) => config + .notices + .external_config_migration_prompts + .project_last_prompted_at + .get(&external_config_migration_project_key(cwd)) + .copied(), + None => { + config + .notices + .external_config_migration_prompts + .home_last_prompted_at + } + } +} + +fn is_external_config_migration_scope_cooling_down( + config: &Config, + cwd: Option<&Path>, + now_unix_seconds: i64, +) -> bool { + external_config_migration_last_prompted_at(config, cwd).is_some_and(|last_prompted_at| { + last_prompted_at.saturating_add(EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS) + > now_unix_seconds + }) +} + +fn visible_external_agent_config_migration_items( + config: &Config, + items: Vec, + now_unix_seconds: i64, +) -> Vec { + items + .into_iter() + .filter(|item| { + !is_external_config_migration_scope_hidden(config, item.cwd.as_deref()) + && !is_external_config_migration_scope_cooling_down( + config, + item.cwd.as_deref(), + now_unix_seconds, + ) + }) + .collect() +} + +fn external_agent_config_migration_success_message( + items: &[ExternalAgentConfigMigrationItem], +) -> String { + if items.iter().any(|item| { + item.item_type == codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Plugins + }) { + "External config migration completed. Plugin migration is still in progress and may take a few minutes." + .to_string() + } else { + "External config migration completed successfully.".to_string() + } +} + +fn unix_seconds_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +async fn persist_external_agent_config_migration_prompt_shown( + config: &mut Config, + items: &[ExternalAgentConfigMigrationItem], + now_unix_seconds: i64, +) -> Result<()> { + let mut edits = Vec::new(); + if items.iter().any(|item| item.cwd.is_none()) { + edits.push( + ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(now_unix_seconds), + ); + } + + for project in items + .iter() + .filter_map(|item| item.cwd.as_deref()) + .map(external_config_migration_project_key) + { + edits.push( + ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( + project, + now_unix_seconds, + ), + ); + } + + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&config.codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("{err}")) + .wrap_err("Failed to save external config migration prompt timestamp")?; + + if items.iter().any(|item| item.cwd.is_none()) { + config + .notices + .external_config_migration_prompts + .home_last_prompted_at = Some(now_unix_seconds); + } + for project in items + .iter() + .filter_map(|item| item.cwd.as_deref()) + .map(external_config_migration_project_key) + { + config + .notices + .external_config_migration_prompts + .project_last_prompted_at + .insert(project, now_unix_seconds); + } + + Ok(()) +} + +async fn persist_external_agent_config_migration_prompt_dismissal( + config: &mut Config, + items: &[ExternalAgentConfigMigrationItem], +) -> Result<()> { + let hide_home = items.iter().any(|item| item.cwd.is_none()); + let projects = items + .iter() + .filter_map(|item| item.cwd.as_deref()) + .map(external_config_migration_project_key) + .collect::>(); + + let mut edits = Vec::new(); + if hide_home + && !config + .notices + .external_config_migration_prompts + .home + .unwrap_or(false) + { + edits.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( + true, + )); + } + for project in &projects { + if !config + .notices + .external_config_migration_prompts + .projects + .get(project) + .copied() + .unwrap_or(false) + { + edits.push( + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + project.clone(), + true, + ), + ); + } + } + + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&config.codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("{err}")) + .wrap_err("Failed to save external config migration prompt preference")?; + + if hide_home { + config.notices.external_config_migration_prompts.home = Some(true); + } + for project in projects { + config + .notices + .external_config_migration_prompts + .projects + .insert(project, true); + } + + Ok(()) +} + +pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed( + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + config: &mut Config, + cli_kv_overrides: &[(String, TomlValue)], + harness_overrides: &ConfigOverrides, + entered_trust_nux: bool, +) -> Result { + if !should_show_external_agent_config_migration_prompt(config, entered_trust_nux) { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + + let now_unix_seconds = unix_seconds_now(); + let detected_items = match app_server + .external_agent_config_detect(ExternalAgentConfigDetectParams { + include_home: true, + cwds: Some(vec![config.cwd.to_path_buf()]), + }) + .await + { + Ok(response) => { + visible_external_agent_config_migration_items(config, response.items, now_unix_seconds) + } + Err(err) => { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to detect external agent config migrations; continuing startup" + ); + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + }; + + if detected_items.is_empty() { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + + if let Err(err) = persist_external_agent_config_migration_prompt_shown( + config, + &detected_items, + now_unix_seconds, + ) + .await + { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to persist external config migration prompt timestamp" + ); + } + + let mut selected_items = detected_items.clone(); + let mut error: Option = None; + + loop { + match run_external_agent_config_migration_prompt( + tui, + &detected_items, + &selected_items, + error.as_deref(), + ) + .await + { + ExternalAgentConfigMigrationOutcome::Proceed(items) => { + selected_items = items.clone(); + match app_server.external_agent_config_import(items).await { + Ok(_) => { + let success_message = + external_agent_config_migration_success_message(&selected_items); + *config = ConfigBuilder::default() + .codex_home(config.codex_home.to_path_buf()) + .cli_overrides(cli_kv_overrides.to_vec()) + .harness_overrides(harness_overrides.clone()) + .build() + .await + .wrap_err("Failed to reload config after external agent migration")?; + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: Some(success_message), + }); + } + Err(err) => { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to import external agent config migration items" + ); + error = Some(format!("Migration failed: {err}")); + } + } + } + ExternalAgentConfigMigrationOutcome::Skip => { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + ExternalAgentConfigMigrationOutcome::SkipForever => { + match persist_external_agent_config_migration_prompt_dismissal( + config, + &detected_items, + ) + .await + { + Ok(()) => { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + Err(err) => { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to persist external config migration prompt dismissal" + ); + error = Some(format!("Failed to save preference: {err}")); + } + } + } + ExternalAgentConfigMigrationOutcome::Exit => { + return Ok(ExternalAgentConfigMigrationStartupOutcome::ExitRequested); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::tempdir; + + #[tokio::test] + async fn visible_external_agent_config_migration_items_omits_hidden_scopes() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config.notices.external_config_migration_prompts.home = Some(true); + config + .notices + .external_config_migration_prompts + .projects + .insert("/tmp/project".to_string(), true); + + let visible = visible_external_agent_config_migration_items( + &config, + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: "home".to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: "project".to_string(), + cwd: Some(PathBuf::from("/tmp/project")), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }, + ], + /*now_unix_seconds*/ 1_760_000_000, + ); + + assert_eq!( + visible, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }] + ); + } + + #[tokio::test] + async fn visible_external_agent_config_migration_items_omits_recently_prompted_scopes() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config + .notices + .external_config_migration_prompts + .home_last_prompted_at = Some(1_760_000_000); + config + .notices + .external_config_migration_prompts + .project_last_prompted_at + .insert("/tmp/project".to_string(), 1_760_000_000); + + let visible = visible_external_agent_config_migration_items( + &config, + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: "home".to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: "project".to_string(), + cwd: Some(PathBuf::from("/tmp/project")), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }, + ], + /*now_unix_seconds*/ + 1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1, + ); + + assert_eq!( + visible, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }] + ); + } + + #[tokio::test] + async fn external_config_migration_scope_cooldown_expires_after_five_days() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config + .notices + .external_config_migration_prompts + .home_last_prompted_at = Some(1_760_000_000); + + assert!(is_external_config_migration_scope_cooling_down( + &config, + /*cwd*/ None, + 1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1, + )); + assert!(!is_external_config_migration_scope_cooling_down( + &config, + /*cwd*/ None, + 1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS, + )); + } + + #[test] + fn external_agent_config_migration_success_message_mentions_plugins_when_present() { + let message = external_agent_config_migration_success_message(&[ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: String::new(), + cwd: None, + details: None, + }, + ]); + + assert_eq!( + message, + "External config migration completed. Plugin migration is still in progress and may take a few minutes." + ); + } + + #[test] + fn external_agent_config_migration_success_message_omits_plugins_copy_when_absent() { + let message = + external_agent_config_migration_success_message(&[ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: None, + details: None, + }]); + + assert_eq!(message, "External config migration completed successfully."); + } + + #[tokio::test] + async fn external_agent_config_migration_prompt_requires_trust_nux_entry() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let _ = config.features.enable(Feature::ExternalMigration); + + assert!(!should_show_external_agent_config_migration_prompt( + &config, /*entered_trust_nux*/ false, + )); + assert!(should_show_external_agent_config_migration_prompt( + &config, /*entered_trust_nux*/ true, + )); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b67dd419f9..c48e49b56d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -118,6 +118,8 @@ mod debug_config; mod diff_render; mod exec_cell; mod exec_command; +mod external_agent_config_migration; +mod external_agent_config_migration_startup; mod external_editor; mod file_search; mod frames; @@ -1452,6 +1454,7 @@ async fn run_ratatui_app( session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? + should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding should_prompt_windows_sandbox_nux_at_startup, remote_url, remote_auth_token, diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap new file mode 100644 index 0000000000..3f518141c8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/external_agent_config_migration.rs +assertion_line: 824 +expression: rendered +--- + + > External agent config detected + We found settings from another agent that you can add to this project. + Select what to import + Home + [x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con… + + Project: /workspace/project + [x] Migrate enabled plugins from /workspace/project/.claude/settings.json… + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Migrate /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md + + Selected 3 of 3 item(s). + 1. Proceed with selected + 2. Skip for now + 3. Don't ask again + Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none From a58a0f083dc4d71d82a6fd1d589ed4964459788a Mon Sep 17 00:00:00 2001 From: Won Park Date: Fri, 17 Apr 2026 18:05:03 -0700 Subject: [PATCH 09/18] Feat/auto review dev message marker (#18369) supporting guardian's rebrand to auto-review! --- codex-rs/protocol/src/models.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8c55d056cd..15dc3d36e8 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -429,7 +429,7 @@ const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); -const GUARDIAN_SUBAGENT_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `guardian_subagent`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; +const AUTO_REVIEW_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); @@ -502,7 +502,7 @@ impl DeveloperInstructions { let text = if approvals_reviewer == ApprovalsReviewer::GuardianSubagent && approval_policy != AskForApproval::Never { - format!("{text}\n\n{GUARDIAN_SUBAGENT_APPROVAL_SUFFIX}") + format!("{text}\n\n{AUTO_REVIEW_APPROVAL_SUFFIX}") } else { text }; @@ -1944,7 +1944,8 @@ mod tests { ) .into_text(); - assert!(text.contains("`approvals_reviewer` is `guardian_subagent`")); + assert!(text.contains("`approvals_reviewer` is `auto_review`")); + assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); assert!(text.contains("materially safer alternative")); } @@ -1959,6 +1960,7 @@ mod tests { ) .into_text(); + assert!(!text.contains("`approvals_reviewer` is `auto_review`")); assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); } From 3f7222ec768f9538d5a0ef8c137ce55e56bef55b Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 17 Apr 2026 18:11:47 -0700 Subject: [PATCH 10/18] feat: Budget skill metadata and surface trimming as a warning (#18298) Cap the model-visible skills section to a small share of the context window, with a fallback character budget, and keep only as many implicit skills as fit within that budget. Emit a non-fatal warning when enabled skills are omitted, and add a new app-server warning notification Record thread-start skill metrics for total enabled skills, kept skills, and whether truncation happened --------- Co-authored-by: Matthew Zeng Co-authored-by: Codex --- codex-rs/Cargo.lock | 1 + .../schema/json/ServerNotification.json | 39 +++ .../codex_app_server_protocol.schemas.json | 41 +++ .../codex_app_server_protocol.v2.schemas.json | 41 +++ .../schema/json/v2/WarningNotification.json | 21 ++ .../schema/typescript/ServerNotification.ts | 3 +- .../typescript/v2/WarningNotification.ts | 13 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 10 + codex-rs/app-server/README.md | 4 + .../app-server/src/bespoke_event_handling.rs | 17 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 116 +++++++ codex-rs/core-skills/Cargo.toml | 1 + codex-rs/core-skills/src/lib.rs | 4 + codex-rs/core-skills/src/render.rs | 284 +++++++++++++++++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 30 +- codex-rs/core/src/session/tests.rs | 194 ++++++++++++ codex-rs/core/src/skills.rs | 3 + codex-rs/otel/src/metrics/names.rs | 3 + codex-rs/tui/src/app/app_server_adapter.rs | 17 ++ codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 2 + .../tui/src/chatwidget/tests/app_server.rs | 51 ++++ 26 files changed, 883 insertions(+), 17 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WarningNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WarningNotification.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4c22d8d732..4091c0d243 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2072,6 +2072,7 @@ dependencies = [ "codex-protocol", "codex-skills", "codex-utils-absolute-path", + "codex-utils-output-truncation", "codex-utils-plugins", "dirs", "dunce", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5695877afa..b0eca67855 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3990,6 +3990,25 @@ } ] }, + "WarningNotification": { + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "type": "object" + }, "WebSearchAction": { "oneOf": [ { @@ -4930,6 +4949,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "warning" + ], + "title": "WarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WarningNotification", + "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 9fb9c3e3e6..d9f79d9202 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 @@ -4303,6 +4303,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "warning" + ], + "title": "WarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WarningNotification", + "type": "object" + }, { "properties": { "method": { @@ -15830,6 +15850,27 @@ ], "type": "string" }, + "WarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "title": "WarningNotification", + "type": "object" + }, "WebSearchAction": { "oneOf": [ { 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 da0381d8b1..6219033869 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 @@ -9787,6 +9787,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "warning" + ], + "title": "WarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WarningNotification", + "type": "object" + }, { "properties": { "method": { @@ -13674,6 +13694,27 @@ ], "type": "string" }, + "WarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "title": "WarningNotification", + "type": "object" + }, "WebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/WarningNotification.json b/codex-rs/app-server-protocol/schema/json/v2/WarningNotification.json new file mode 100644 index 0000000000..460486896d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WarningNotification.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "title": "WarningNotification", + "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 f6df421dcf..a43bb80428 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -54,10 +54,11 @@ import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; +import type { WarningNotification } from "./v2/WarningNotification"; import type { WindowsSandboxSetupCompletedNotification } from "./v2/WindowsSandboxSetupCompletedNotification"; import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldWritableWarningNotification"; /** * 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/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": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "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": "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": "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/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": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "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": "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": "warning", "params": WarningNotification } | { "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/WarningNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WarningNotification.ts new file mode 100644 index 0000000000..bd3433be41 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WarningNotification.ts @@ -0,0 +1,13 @@ +// 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 WarningNotification = { +/** + * Optional thread target when the warning applies to a specific thread. + */ +threadId: string | null, +/** + * Concise warning message for the user. + */ +message: string, }; 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 f9884efb7c..6ae7ebb7df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -362,6 +362,7 @@ export type { TurnStatus } from "./TurnStatus"; export type { TurnSteerParams } from "./TurnSteerParams"; export type { TurnSteerResponse } from "./TurnSteerResponse"; export type { UserInput } from "./UserInput"; +export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification"; export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c29dbccd50..f4461050e0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1029,6 +1029,7 @@ server_notification_definitions! { /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), + Warning => "warning" (v2::WarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 340a80a618..6390b8f100 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -6689,6 +6689,16 @@ pub struct DeprecationNoticeNotification { pub details: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WarningNotification { + /// Optional thread target when the warning applies to a specific thread. + pub thread_id: Option, + /// Concise warning message for the user. + pub message: String, +} + #[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 fcc19ce42d..2ade95b6ac 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -958,6 +958,10 @@ Event notifications are the server-initiated event stream for thread lifecycles, Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`. +Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics. + +Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session. + ### Notification opt-out Clients can suppress specific notifications per connection by sending exact method names in `initialize.params.capabilities.optOutNotificationMethods`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f411514051..6acc1e2fcb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -101,6 +101,7 @@ use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WarningNotification; use codex_app_server_protocol::build_command_execution_end_item; use codex_app_server_protocol::build_file_change_approval_request_item; use codex_app_server_protocol::build_file_change_begin_item; @@ -268,7 +269,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::Warning(_warning_event) => {} + EventMsg::Warning(warning_event) => { + if let ApiVersion::V2 = api_version { + let notification = WarningNotification { + thread_id: Some(conversation_id.to_string()), + message: warning_event.message, + }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::Warning(notification.clone())); + } + outgoing + .send_server_notification(ServerNotification::Warning(notification)) + .await; + } + } EventMsg::GuardianAssessment(assessment) => { if let ApiVersion::V2 = api_version { let pending_command_execution = match build_item_from_guardian_event( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index aa058c583e..d2ac241edb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4314,6 +4314,7 @@ impl CodexMessageProcessor { thread_id, thread: codex_thread, session_configured, + .. }) => { let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { 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 e8682d7325..9f491b3368 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -11,6 +11,7 @@ use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use app_test_support::write_models_cache; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -45,6 +46,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WarningNotification; use codex_config::config_toml::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; use codex_features::FEATURES; @@ -244,6 +246,111 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + write_models_cache(codex_home.path())?; + let cache_path = codex_home.path().join("models_cache.json"); + let mut cache: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?; + let models = cache["models"] + .as_array_mut() + .expect("models_cache.json models should be an array"); + let entry = models + .first_mut() + .expect("models cache should not be empty"); + let model = entry["slug"] + .as_str() + .expect("model slug should be present") + .to_string(); + entry["context_window"] = serde_json::Value::from(100); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("model = \"mock-model\"", &format!("model = \"{model}\"")), + )?; + write_test_skill(codex_home.path(), "alpha-skill")?; + write_test_skill(codex_home.path(), "beta-skill")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::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(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("warning"), + ) + .await??; + let params = notification.params.expect("warning params"); + let warning: WarningNotification = + serde_json::from_value(params).expect("deserialize warning notification"); + assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str())); + assert_eq!( + warning.message, + "Some enabled skills were not included in the model-visible skills list for this session. Mention a skill by name or path if you need it." + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + let request = requests + .last() + .expect("expected at least one model request"); + assert!( + body_contains(request, "## Skills"), + "expected outgoing request to include the skills section" + ); + assert!( + !body_contains(request, "- alpha-skill:") && !body_contains(request, "- beta-skill:"), + "expected trimmed skills to be omitted from the outgoing request body" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { let server = responses::start_mock_server().await; @@ -2902,3 +3009,12 @@ stream_max_retries = 0 ), ) } + +fn write_test_skill(codex_home: &Path, name: &str) -> std::io::Result<()> { + let skill_dir = codex_home.join("skills").join(name); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n"), + ) +} diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index bc73909f92..09278c8d0e 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -24,6 +24,7 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-skills = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-output-truncation = { workspace = true } codex-utils-plugins = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index b967e4dddc..6d3ac7a4fe 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -22,4 +22,8 @@ pub use model::SkillLoadOutcome; pub use model::SkillMetadata; pub use model::SkillPolicy; pub use model::filter_skill_load_outcome_for_product; +pub use render::RenderedSkillsSection; +pub use render::SkillMetadataBudget; +pub use render::SkillRenderReport; +pub use render::default_skill_metadata_budget; pub use render::render_skills_section; diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 9089a05436..ad674fbffa 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,22 +1,104 @@ use crate::model::SkillMetadata; +use codex_otel::SessionTelemetry; +use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC; +use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC; +use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; +use codex_protocol::protocol::SkillScope; +use codex_utils_output_truncation::approx_token_count; -pub fn render_skills_section(skills: &[SkillMetadata]) -> Option { +const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; +const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillMetadataBudget { + Tokens(usize), + Characters(usize), +} + +impl SkillMetadataBudget { + fn limit(self) -> usize { + match self { + Self::Tokens(limit) | Self::Characters(limit) => limit, + } + } + + fn cost(self, text: &str) -> usize { + match self { + Self::Tokens(_) => approx_token_count(text), + Self::Characters(_) => text.chars().count(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillRenderReport { + pub total_count: usize, + pub included_count: usize, + pub omitted_count: usize, +} + +#[derive(Clone, Copy)] +pub enum SkillRenderSideEffects<'a> { + None, + ThreadStart { + session_telemetry: &'a SessionTelemetry, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedSkillsSection { + pub text: String, + pub report: SkillRenderReport, + pub emit_warning: bool, +} + +pub fn default_skill_metadata_budget(context_window: Option) -> SkillMetadataBudget { + context_window + .and_then(|window| usize::try_from(window).ok()) + .filter(|window| *window > 0) + .map(|window| { + SkillMetadataBudget::Tokens( + window + .saturating_mul(SKILL_METADATA_CONTEXT_WINDOW_PERCENT) + .saturating_div(100) + .max(1), + ) + }) + .unwrap_or(SkillMetadataBudget::Characters( + DEFAULT_SKILL_METADATA_CHAR_BUDGET, + )) +} + +pub fn render_skills_section( + skills: &[SkillMetadata], + budget: SkillMetadataBudget, + side_effects: SkillRenderSideEffects<'_>, +) -> Option { if skills.is_empty() { + let _ = record_skill_render_side_effects( + side_effects, + /*total_count*/ 0, + /*included_count*/ 0, + /*truncated*/ false, + ); return None; } + let (skill_lines, report) = render_skill_lines(skills, budget); + let emit_warning = record_skill_render_side_effects( + side_effects, + report.total_count, + report.included_count, + report.omitted_count > 0, + ); let mut lines: Vec = Vec::new(); lines.push("## Skills".to_string()); lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string()); lines.push("### Available skills".to_string()); - - for skill in skills { - let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/"); - let name = skill.name.as_str(); - let description = skill.description.as_str(); - lines.push(format!("- {name}: {description} (file: {path_str})")); + if !skill_lines.is_empty() { + lines.extend(skill_lines); } lines.push("### How to use skills".to_string()); @@ -42,7 +124,189 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option { ); let body = lines.join("\n"); - Some(format!( - "{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}" - )) + Some(RenderedSkillsSection { + text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"), + report, + emit_warning, + }) +} + +fn record_skill_render_side_effects( + side_effects: SkillRenderSideEffects<'_>, + total_count: usize, + included_count: usize, + truncated: bool, +) -> bool { + match side_effects { + SkillRenderSideEffects::None => false, + SkillRenderSideEffects::ThreadStart { session_telemetry } => { + session_telemetry.histogram( + THREAD_SKILLS_ENABLED_TOTAL_METRIC, + i64::try_from(total_count).unwrap_or(i64::MAX), + &[], + ); + session_telemetry.histogram( + THREAD_SKILLS_KEPT_TOTAL_METRIC, + i64::try_from(included_count).unwrap_or(i64::MAX), + &[], + ); + session_telemetry.histogram( + THREAD_SKILLS_TRUNCATED_METRIC, + if truncated { 1 } else { 0 }, + &[], + ); + truncated + } + } +} + +fn render_skill_lines( + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> (Vec, SkillRenderReport) { + let ordered_skills = ordered_skills_for_budget(skills); + + let mut included = Vec::new(); + let mut used = 0usize; + let mut omitted_count = 0usize; + + for skill in ordered_skills { + let line = render_skill_line(skill); + let line_cost = budget.cost(&format!("{line}\n")); + if used.saturating_add(line_cost) <= budget.limit() { + used = used.saturating_add(line_cost); + included.push(line); + continue; + } + + omitted_count = omitted_count.saturating_add(1); + } + + let report = SkillRenderReport { + total_count: skills.len(), + included_count: included.len(), + omitted_count, + }; + + (included, report) +} + +fn ordered_skills_for_budget(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> { + let mut ordered = skills.iter().collect::>(); + ordered.sort_by(|a, b| { + prompt_scope_rank(a.scope) + .cmp(&prompt_scope_rank(b.scope)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) + }); + ordered +} + +fn prompt_scope_rank(scope: SkillScope) -> u8 { + match scope { + SkillScope::System => 0, + SkillScope::Admin => 1, + SkillScope::Repo => 2, + SkillScope::User => 3, + } +} + +fn render_skill_line(skill: &SkillMetadata) -> String { + let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/"); + let name = skill.name.as_str(); + let description = skill.description.as_str(); + format!("- {name}: {description} (file: {path_str})") +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + + fn make_skill(name: &str, scope: SkillScope) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(), + scope, + } + } + + #[test] + fn default_budget_uses_two_percent_of_full_context_window() { + assert_eq!( + default_skill_metadata_budget(Some(200_000)), + SkillMetadataBudget::Tokens(4_000) + ); + assert_eq!( + default_skill_metadata_budget(Some(99)), + SkillMetadataBudget::Tokens(1) + ); + } + + #[test] + fn default_budget_falls_back_to_characters_without_context_window() { + assert_eq!( + default_skill_metadata_budget(/*context_window*/ None), + SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET) + ); + assert_eq!( + default_skill_metadata_budget(Some(-1)), + SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET) + ); + } + + #[test] + fn budgeted_rendering_preserves_prompt_priority() { + let system = make_skill("system-skill", SkillScope::System); + let user = make_skill("user-skill", SkillScope::User); + let repo = make_skill("repo-skill", SkillScope::Repo); + let admin = make_skill("admin-skill", SkillScope::Admin); + let system_cost = SkillMetadataBudget::Characters(usize::MAX) + .cost(&format!("{}\n", render_skill_line(&system))); + let admin_cost = SkillMetadataBudget::Characters(usize::MAX) + .cost(&format!("{}\n", render_skill_line(&admin))); + let budget = SkillMetadataBudget::Characters(system_cost + admin_cost); + + let rendered = render_skills_section( + &[system, user, repo, admin], + budget, + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 2); + assert!(!rendered.emit_warning); + assert!(rendered.text.contains("- system-skill:")); + assert!(rendered.text.contains("- admin-skill:")); + assert!(!rendered.text.contains("- repo-skill:")); + assert!(!rendered.text.contains("- user-skill:")); + } + + #[test] + fn budgeted_rendering_keeps_scanning_after_oversized_entry() { + let mut oversized = make_skill("oversized-system-skill", SkillScope::System); + oversized.description = "desc ".repeat(100); + let repo = make_skill("repo-skill", SkillScope::Repo); + let repo_cost = SkillMetadataBudget::Characters(usize::MAX) + .cost(&format!("{}\n", render_skill_line(&repo))); + let budget = SkillMetadataBudget::Characters(repo_cost); + + let rendered = + render_skills_section(&[oversized, repo], budget, SkillRenderSideEffects::None) + .expect("skills render"); + + assert_eq!(rendered.report.included_count, 1); + assert_eq!(rendered.report.omitted_count, 1); + assert!(!rendered.emit_warning); + assert!(!rendered.text.contains("- oversized-system-skill:")); + assert!(rendered.text.contains("- repo-skill:")); + } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 18d1a30292..1fef33c3f3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -99,6 +99,7 @@ pub(crate) use skills::build_skill_injections; pub(crate) use skills::build_skill_name_counts; pub(crate) use skills::collect_env_var_dependencies; pub(crate) use skills::collect_explicit_skill_mentions; +pub(crate) use skills::default_skill_metadata_budget; pub(crate) use skills::injection; pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 766bd04e69..594a2d1034 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -20,6 +20,7 @@ use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; use crate::config::ManagedFeatures; use crate::connectors; +use crate::default_skill_metadata_budget; use crate::exec_policy::ExecPolicyManager; use crate::installation_id::resolve_installation_id; use crate::parse_turn_item; @@ -28,6 +29,7 @@ use crate::realtime_conversation::RealtimeConversationManager; use crate::render_skills_section; use crate::rollout::find_thread_name_by_id; use crate::session_prefix::format_subagent_notification_message; +use crate::skills::SkillRenderSideEffects; use crate::skills_load_input_from_config; use crate::turn_metadata::TurnMetadataState; use async_channel::Receiver; @@ -362,9 +364,10 @@ pub struct Codex { pub(crate) type SessionLoopTermination = Shared>; -/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], -/// the submission id for the initial `ConfigureSession` request and the -/// unique session id. +pub(crate) const THREAD_START_SKILLS_TRIMMED_WARNING_MESSAGE: &str = "Some enabled skills were not included in the model-visible skills list for this session. Mention a skill by name or path if you need it."; + +/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`] and +/// the unique session id. pub struct CodexSpawnOk { pub codex: Codex, pub thread_id: ThreadId, @@ -396,6 +399,7 @@ pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512; const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety"; + impl Codex { /// Spawn a new [`Codex`] and initialize the session. pub(crate) async fn spawn(args: CodexSpawnArgs) -> CodexResult { @@ -2422,8 +2426,24 @@ impl Session { .turn_skills .outcome .allowed_skills_for_implicit_invocation(); - if let Some(skills_section) = render_skills_section(&implicit_skills) { - developer_sections.push(skills_section); + let rendered_skills = render_skills_section( + &implicit_skills, + default_skill_metadata_budget(turn_context.model_info.context_window), + SkillRenderSideEffects::ThreadStart { + session_telemetry: &self.services.session_telemetry, + }, + ); + if let Some(rendered_skills) = rendered_skills { + if rendered_skills.emit_warning { + self.send_event_raw(Event { + id: String::new(), + msg: EventMsg::Warning(WarningEvent { + message: THREAD_START_SKILLS_TRIMMED_WARNING_MESSAGE.to_string(), + }), + }) + .await; + } + developer_sections.push(rendered_skills.text); } let loaded_plugins = self .services diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 60183e986c..49cacab04d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -12,6 +12,8 @@ use crate::config_loader::project_trust_key; use crate::exec::ExecCapturePolicy; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; +use crate::skills::SkillRenderSideEffects; +use crate::skills::render::SkillMetadataBudget; use crate::tools::format_exec_output_str; use codex_features::Feature; @@ -61,6 +63,11 @@ use codex_execpolicy::Decision; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use codex_network_proxy::NetworkProxyConfig; +use codex_otel::MetricsClient; +use codex_otel::MetricsConfig; +use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC; +use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC; +use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC; use codex_otel::TelemetryAuthMode; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -86,6 +93,7 @@ use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TokenCountEvent; @@ -106,10 +114,16 @@ 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::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 opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; +use opentelemetry_sdk::metrics::InMemoryMetricExporter; +use opentelemetry_sdk::metrics::data::AggregatedMetrics; +use opentelemetry_sdk::metrics::data::Metric; +use opentelemetry_sdk::metrics::data::MetricData; +use opentelemetry_sdk::metrics::data::ResourceMetrics; use std::path::Path; use std::time::Duration; use tokio::time::sleep; @@ -155,6 +169,54 @@ fn assistant_message(text: &str) -> ResponseItem { } } +fn test_session_telemetry_without_metadata() -> SessionTelemetry { + let exporter = InMemoryMetricExporter::default(); + let metrics = MetricsClient::new( + MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter) + .with_runtime_reader(), + ) + .expect("in-memory metrics client"); + SessionTelemetry::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics) +} + +fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric { + for scope_metrics in resource_metrics.scope_metrics() { + for metric in scope_metrics.metrics() { + if metric.name() == name { + return metric; + } + } + } + panic!("metric {name} missing"); +} + +fn histogram_sum(resource_metrics: &ResourceMetrics, name: &str) -> u64 { + let metric = find_metric(resource_metrics, name); + match metric.data() { + AggregatedMetrics::F64(data) => match data { + MetricData::Histogram(histogram) => { + let points: Vec<_> = histogram.data_points().collect(); + assert_eq!(points.len(), 1); + points[0].sum().round() as u64 + } + _ => panic!("unexpected histogram aggregation"), + }, + _ => panic!("unexpected metric data type"), + } +} + fn skill_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -4482,6 +4544,138 @@ async fn build_initial_context_omits_default_image_save_location_without_image_h ); } +#[tokio::test] +async fn build_initial_context_trims_skill_metadata_from_context_window_budget() { + let (session, mut turn_context) = make_session_and_context().await; + let mut outcome = SkillLoadOutcome::default(); + outcome.skills = vec![ + SkillMetadata { + name: "admin-skill".to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf("/tmp/admin-skill/SKILL.md").abs(), + scope: SkillScope::Admin, + }, + SkillMetadata { + name: "repo-skill".to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), + scope: SkillScope::Repo, + }, + ]; + turn_context.model_info.context_window = Some(100); + turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome)); + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_texts = developer_input_texts(&initial_context); + + assert!( + developer_texts + .iter() + .all(|text| !text.contains(THREAD_START_SKILLS_TRIMMED_WARNING_MESSAGE)), + "expected skill budget warning to stay out of the initial context, got {developer_texts:?}" + ); + assert!( + developer_texts + .iter() + .all(|text| !text.contains("- admin-skill:") && !text.contains("- repo-skill:")), + "expected no skill metadata entries to fit the tiny budget, got {developer_texts:?}" + ); +} + +#[test] +fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() { + let session_telemetry = test_session_telemetry_without_metadata(); + let rendered = render_skills_section( + &[SkillMetadata { + name: "repo-skill".to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), + scope: SkillScope::Repo, + }], + SkillMetadataBudget::Characters(1), + SkillRenderSideEffects::ThreadStart { + session_telemetry: &session_telemetry, + }, + ) + .expect("skills should render"); + + assert!(rendered.emit_warning); + let snapshot = session_telemetry + .snapshot_metrics() + .expect("runtime metrics snapshot"); + assert_eq!( + histogram_sum(&snapshot, THREAD_SKILLS_ENABLED_TOTAL_METRIC), + 1 + ); + assert_eq!(histogram_sum(&snapshot, THREAD_SKILLS_KEPT_TOTAL_METRIC), 0); + assert_eq!(histogram_sum(&snapshot, THREAD_SKILLS_TRUNCATED_METRIC), 1); +} + +#[tokio::test] +async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_builds() { + let (session, turn_context, rx) = make_session_and_context_with_rx().await; + let mut turn_context = Arc::into_inner(turn_context).expect("sole turn context owner"); + let mut outcome = SkillLoadOutcome::default(); + outcome.skills = vec![ + SkillMetadata { + name: "admin-skill".to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf("/tmp/admin-skill/SKILL.md").abs(), + scope: SkillScope::Admin, + }, + SkillMetadata { + name: "repo-skill".to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), + scope: SkillScope::Repo, + }, + ]; + turn_context.model_info.context_window = Some(100); + turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome)); + + let _ = session.build_initial_context(&turn_context).await; + let warning_event = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("warning event should arrive") + .expect("warning event should be readable"); + assert!(matches!( + warning_event.msg, + EventMsg::Warning(WarningEvent { message }) + if message == THREAD_START_SKILLS_TRIMMED_WARNING_MESSAGE + )); + + let _ = session.build_initial_context(&turn_context).await; + let warning_event = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("warning event should arrive on repeated build") + .expect("warning event should be readable"); + assert!(matches!( + warning_event.msg, + EventMsg::Warning(WarningEvent { message }) + if message == THREAD_START_SKILLS_TRIMMED_WARNING_MESSAGE + )); +} + #[tokio::test] async fn handle_output_item_done_records_image_save_history_message() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 84c0af5f3e..552587318b 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -21,11 +21,13 @@ pub use codex_core_skills::SkillError; pub use codex_core_skills::SkillLoadOutcome; pub use codex_core_skills::SkillMetadata; pub use codex_core_skills::SkillPolicy; +pub use codex_core_skills::SkillRenderReport; pub use codex_core_skills::SkillsLoadInput; pub use codex_core_skills::SkillsManager; pub use codex_core_skills::build_skill_name_counts; pub use codex_core_skills::collect_env_var_dependencies; pub use codex_core_skills::config_rules; +pub use codex_core_skills::default_skill_metadata_budget; pub use codex_core_skills::detect_implicit_skill_invocation_for_command; pub use codex_core_skills::filter_skill_load_outcome_for_product; pub use codex_core_skills::injection; @@ -37,6 +39,7 @@ pub use codex_core_skills::manager; pub use codex_core_skills::model; pub use codex_core_skills::remote; pub use codex_core_skills::render; +pub use codex_core_skills::render::SkillRenderSideEffects; pub use codex_core_skills::render_skills_section; pub use codex_core_skills::system; diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index fdc03fbaab..9dc718d2d7 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -37,3 +37,6 @@ pub const STARTUP_PREWARM_DURATION_METRIC: &str = "codex.startup_prewarm.duratio pub const STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC: &str = "codex.startup_prewarm.age_at_first_turn_ms"; pub const THREAD_STARTED_METRIC: &str = "codex.thread.started"; +pub const THREAD_SKILLS_ENABLED_TOTAL_METRIC: &str = "codex.thread.skills.enabled_total"; +pub const THREAD_SKILLS_KEPT_TOTAL_METRIC: &str = "codex.thread.skills.kept_total"; +pub const THREAD_SKILLS_TRUNCATED_METRIC: &str = "codex.thread.skills.truncated"; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 3dfc9fd235..f0abb5326e 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -421,6 +421,7 @@ fn server_notification_thread_target( ServerNotification::ThreadRealtimeClosed(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::Warning(notification) => notification.thread_id.as_deref(), ServerNotification::SkillsChanged(_) | ServerNotification::McpServerStatusUpdated(_) | ServerNotification::McpServerOauthLoginCompleted(_) @@ -1049,8 +1050,10 @@ fn app_server_codex_error_info_to_core( #[cfg(test)] mod tests { + use super::ServerNotificationThreadTarget; use super::command_execution_started_event; use super::server_notification_thread_events; + use super::server_notification_thread_target; use super::thread_snapshot_events; use super::turn_snapshot_events; use codex_app_server_protocol::AgentMessageDeltaNotification; @@ -1070,6 +1073,7 @@ mod tests { use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnStatus; + use codex_app_server_protocol::WarningNotification; use codex_protocol::ThreadId; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; @@ -1664,4 +1668,17 @@ mod tests { assert_eq!(raw_reasoning.text, "hidden chain"); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); } + + #[test] + fn warning_notifications_route_to_threads_when_thread_id_is_present() { + let thread_id = ThreadId::new(); + let notification = ServerNotification::Warning(WarningNotification { + thread_id: Some(thread_id.to_string()), + message: "warning".to_string(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3fc8df0b55..2c88baf85e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6196,6 +6196,7 @@ impl ChatWidget { self.refresh_skills_for_current_cwd(/*force_reload*/ true); } ServerNotification::ModelRerouted(_) => {} + ServerNotification::Warning(notification) => self.on_warning(notification.message), ServerNotification::DeprecationNotice(notification) => { self.on_deprecation_notice(DeprecationNoticeEvent { summary: notification.summary, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e92a809e80..6067f3a768 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -46,6 +46,7 @@ pub(super) use codex_app_server_protocol::CommandAction as AppServerCommandActio pub(super) use codex_app_server_protocol::CommandExecutionRequestApprovalParams as AppServerCommandExecutionRequestApprovalParams; pub(super) use codex_app_server_protocol::CommandExecutionSource as AppServerCommandExecutionSource; pub(super) use codex_app_server_protocol::CommandExecutionStatus as AppServerCommandExecutionStatus; +pub(super) use codex_app_server_protocol::ConfigWarningNotification; pub(super) use codex_app_server_protocol::ErrorNotification; pub(super) use codex_app_server_protocol::FileUpdateChange; pub(super) use codex_app_server_protocol::GuardianApprovalReview; @@ -94,6 +95,7 @@ pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError; pub(super) use codex_app_server_protocol::TurnStartedNotification; pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput; +pub(super) use codex_app_server_protocol::WarningNotification; pub(super) use codex_config::types::ApprovalsReviewer; pub(super) use codex_config::types::Notifications; #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index acfd358a25..702bd08ef9 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -184,6 +184,57 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() { ); } +#[tokio::test] +async fn live_app_server_warning_notification_renders_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::Warning(WarningNotification { + thread_id: None, + message: "Some enabled skills were not included in the model-visible skills list for this session. Mention a skill by name or path if you need it.".to_string(), + }), + /*replay_kind*/ None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + let normalized = rendered.split_whitespace().collect::>().join(" "); + assert!( + normalized.contains( + "Some enabled skills were not included in the model-visible skills list for this session." + ), + "expected warning notification message, got {rendered}" + ); + assert!( + normalized.contains("Mention a skill by name or path if you need it."), + "expected warning guidance, got {rendered}" + ); +} + +#[tokio::test] +async fn live_app_server_config_warning_prefixes_summary() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "Invalid configuration; using defaults.".to_string(), + details: None, + path: None, + range: None, + }), + /*replay_kind*/ None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Invalid configuration; using defaults."), + "expected config warning summary, got {rendered}" + ); +} + #[tokio::test] async fn live_app_server_file_change_item_started_preserves_changes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From def6467d2bb7c9ff6333d59b6f64a0acf839817a Mon Sep 17 00:00:00 2001 From: xli-oai Date: Fri, 17 Apr 2026 20:31:13 -0700 Subject: [PATCH 11/18] [codex] Describe uninstalled cross-repo plugin reads (#18449) ## Summary - Populate `PluginDetail.description` in core for uninstalled cross-repo plugins when detailed fields are unavailable until install. - Include the source Git URL plus optional path/ref/sha details in that fallback description. - Keep `details_unavailable_reason` as the structured signal while app-server forwards the description normally. - Add plugin-read coverage proving the response does not clone the remote source just to show the message. ## Why Uninstalled cross-repo plugins intentionally return sparse detail data so listing/reading does not clone the plugin source. Without a description, Desktop and TUI detail pages look like an ordinary empty plugin. This gives users a concrete explanation and source pointer while keeping the existing structured reason available for callers. ## Validation - `just fmt` - `cargo test -p codex-core read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning` - `cargo test -p codex-app-server plugin_read --test all` - `just fix -p codex-core` - `just fix -p codex-app-server` Note: `cargo test -p codex-app-server` was also attempted before the latest refactor and failed broadly in unrelated v2 thread/realtime/review/skills suites; the new plugin-read test passed in that run as well. --- .../app-server/tests/suite/v2/plugin_read.rs | 70 +++++++++++++++++++ codex-rs/core/src/plugins/manager.rs | 31 +++++++- codex-rs/core/src/plugins/manager_tests.rs | 8 ++- 3 files changed, 107 insertions(+), 2 deletions(-) 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 f5681e9d4d..e79a72a920 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -511,6 +511,76 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }} + }} + ] +}}"# + ), + )?; + write_plugins_enabled_config(&codex_home)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "toolkit".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + let expected_description = format!( + "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`." + ); + assert_eq!( + response.plugin.description.as_deref(), + Some(expected_description.as_str()) + ); + assert!(!response.plugin.summary.installed); + assert!(response.plugin.skills.is_empty()); + assert!(response.plugin.apps.is_empty()); + assert!(response.plugin.mcp_servers.is_empty()); + assert!( + !codex_home + .path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); + Ok(()) +} + #[tokio::test] async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 2be36210e7..794d1f2434 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1058,10 +1058,11 @@ impl PluginsManager { })?; let plugin_key = plugin_id.as_key(); if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && !plugin.installed { + let description = remote_plugin_install_required_description(&plugin.source); return Ok(PluginDetail { id: plugin_key, name: plugin.name, - description: None, + description: Some(description), source: plugin.source, policy: plugin.policy, interface: plugin.interface, @@ -1495,6 +1496,34 @@ impl PluginsManager { } } +fn remote_plugin_install_required_description(source: &MarketplacePluginSource) -> String { + let source_description = match source { + MarketplacePluginSource::Git { + url, + path, + ref_name, + sha, + } => { + let mut parts = vec![url.clone()]; + if let Some(path) = path { + parts.push(format!("path `{path}`")); + } + if let Some(ref_name) = ref_name { + parts.push(format!("ref `{ref_name}`")); + } + if let Some(sha) = sha { + parts.push(format!("sha `{sha}`")); + } + parts.join(", ") + } + MarketplacePluginSource::Local { path } => path.as_path().display().to_string(), + }; + + format!( + "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {source_description}." + ) +} + #[derive(Debug, thiserror::Error)] pub enum PluginInstallError { #[error("{0}")] diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 4f327637b0..e43490803d 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1623,7 +1623,13 @@ plugins = true Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource) ); assert!(!outcome.plugin.installed); - assert!(outcome.plugin.description.is_none()); + let expected_description = format!( + "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`." + ); + assert_eq!( + outcome.plugin.description.as_deref(), + Some(expected_description.as_str()) + ); assert!(outcome.plugin.skills.is_empty()); assert!(outcome.plugin.apps.is_empty()); assert!(outcome.plugin.mcp_server_names.is_empty()); From 6b39d0c657924403b77040186012e911344342c0 Mon Sep 17 00:00:00 2001 From: richardopenai Date: Fri, 17 Apr 2026 21:41:57 -0700 Subject: [PATCH 12/18] [codex] Add owner nudge app-server API (#18220) ## Summary Second PR in the split from #17956. Stacked on #18227. - adds app-server v2 protocol/schema support for `account/sendAddCreditsNudgeEmail` - adds the backend-client `send_add_credits_nudge_email` request and request body mapping - handles the app-server request with auth checks, backend call, and cooldown mapping - adds the disabled `workspace_owner_usage_nudge` feature flag and focused app-server/backend tests ## Validation - `cargo test -p codex-backend-client` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server rate_limits` - `cargo test -p codex-tui workspace_` - `cargo test -p codex-tui status_` - `just fmt` - `just fix -p codex-backend-client` - `just fix -p codex-app-server-protocol` - `just fix -p codex-app-server` - `just fix -p codex-tui` --- .../schema/json/ClientRequest.json | 42 ++++ .../codex_app_server_protocol.schemas.json | 64 ++++++ .../codex_app_server_protocol.v2.schemas.json | 64 ++++++ .../v2/SendAddCreditsNudgeEmailParams.json | 22 ++ .../v2/SendAddCreditsNudgeEmailResponse.json | 22 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/AddCreditsNudgeCreditType.ts | 5 + .../v2/AddCreditsNudgeEmailStatus.ts | 5 + .../v2/SendAddCreditsNudgeEmailParams.ts | 6 + .../v2/SendAddCreditsNudgeEmailResponse.ts | 6 + .../schema/typescript/v2/index.ts | 4 + .../src/protocol/common.rs | 5 + .../app-server-protocol/src/protocol/v2.rs | 30 +++ codex-rs/app-server/README.md | 13 +- .../app-server/src/codex_message_processor.rs | 77 +++++++ .../app-server/tests/common/mcp_process.rs | 11 + .../app-server/tests/suite/v2/rate_limits.rs | 208 ++++++++++++++++++ codex-rs/backend-client/src/client.rs | 89 ++++++++ codex-rs/backend-client/src/lib.rs | 1 + 19 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d195d57b6c..3c6a8c4678 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,13 @@ "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" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "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`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -2548,6 +2555,17 @@ } ] }, + "SendAddCreditsNudgeEmailParams": { + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "type": "object" + }, "ServiceTier": { "enum": [ "fast", @@ -4982,6 +5000,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "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 d9f79d9202..6ce0d2190b 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 @@ -1440,6 +1440,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { @@ -5103,6 +5127,20 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12267,6 +12305,32 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/v2/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, "ServerRequestResolvedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 6219033869..74cde880e0 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 @@ -114,6 +114,20 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2022,6 +2036,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { @@ -8999,6 +9037,32 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, "ServerNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Notification sent from the server to the client.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json new file mode 100644 index 0000000000..c3c63edef0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + } + }, + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json new file mode 100644 index 0000000000..bfeba322b2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "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 d21f951458..b9ef6401b7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -43,6 +43,7 @@ import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; +import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; @@ -69,4 +70,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/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/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/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "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": "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": "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/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/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/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "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": "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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts new file mode 100644 index 0000000000..70498d6a67 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.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 AddCreditsNudgeCreditType = "credits" | "usage_limit"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts new file mode 100644 index 0000000000..2b62da68ea --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.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 AddCreditsNudgeEmailStatus = "sent" | "cooldown_active"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts new file mode 100644 index 0000000000..383ad4aab3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.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 { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; + +export type SendAddCreditsNudgeEmailParams = { creditType: AddCreditsNudgeCreditType, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts new file mode 100644 index 0000000000..71dcb190a6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.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 { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; + +export type SendAddCreditsNudgeEmailResponse = { status: AddCreditsNudgeEmailStatus, }; 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 6ae7ebb7df..cbdef12100 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,6 +4,8 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; +export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; +export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; @@ -259,6 +261,8 @@ export type { ReviewTarget } from "./ReviewTarget"; export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; +export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams"; +export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse"; export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification"; export type { SessionSource } from "./SessionSource"; export type { SkillDependencies } from "./SkillDependencies"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f4461050e0..4e5882f83a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -524,6 +524,11 @@ client_request_definitions! { response: v2::GetAccountRateLimitsResponse, }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { + params: v2::SendAddCreditsNudgeEmailParams, + response: v2::SendAddCreditsNudgeEmailResponse, + }, + FeedbackUpload => "feedback/upload" { params: v2::FeedbackUploadParams, response: v2::FeedbackUploadResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6390b8f100..508b90b340 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1789,6 +1789,36 @@ 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 SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, 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 2ade95b6ac..e34e44d8c4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1448,6 +1448,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `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; updates arrive via `account/rateLimits/updated` (notify). - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `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`. @@ -1540,7 +1541,7 @@ Field notes: ```json { "method": "account/rateLimits/read", "id": 7 } -{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } } { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` @@ -1549,6 +1550,16 @@ Field notes: - `usedPercent` is current usage within the OpenAI quota window. - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. +- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. + +### 8) Notify a workspace owner about a limit + +```json +{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } } +{ "id": 8, "result": { "status": "sent" } } +``` + +Use `creditType: "credits"` when workspace credits are depleted, or `creditType: "usage_limit"` when the workspace usage limit has been reached. If the owner was already notified recently, the response status is `cooldown_active`. ## Experimental API Opt-in diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d2ac241edb..69cbd6cf4e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -29,6 +29,8 @@ use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; @@ -117,6 +119,8 @@ use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::SkillSummary; @@ -203,6 +207,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_arg0::Arg0DispatchPaths; +use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; @@ -1187,6 +1192,10 @@ impl CodexMessageProcessor { self.get_account_rate_limits(to_connection_request_id(request_id)) .await; } + ClientRequest::SendAddCreditsNudgeEmail { request_id, params } => { + self.send_add_credits_nudge_email(to_connection_request_id(request_id), params) + .await; + } ClientRequest::FeedbackUpload { request_id, params } => { self.upload_feedback(to_connection_request_id(request_id), params) .await; @@ -1901,6 +1910,74 @@ impl CodexMessageProcessor { } } + async fn send_add_credits_nudge_email( + &self, + request_id: ConnectionRequestId, + params: SendAddCreditsNudgeEmailParams, + ) { + match self.send_add_credits_nudge_email_inner(params).await { + Ok(status) => { + self.outgoing + .send_response(request_id, SendAddCreditsNudgeEmailResponse { status }) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn send_add_credits_nudge_email_inner( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to notify workspace owner" + .to_string(), + data: None, + }); + }; + + if !auth.is_chatgpt_auth() { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to notify workspace owner".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + match client + .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) + .await + { + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { + Ok(AddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to notify workspace owner: {err}"), + data: None, + }), + } + } + + fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { + match value { + AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, + AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, + } + } + async fn fetch_account_rate_limits( &self, ) -> Result< diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 27bacbcd23..05bb66d57d 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -58,6 +58,7 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; @@ -305,6 +306,16 @@ impl McpProcess { .await } + /// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request. + pub async fn send_add_credits_nudge_email_request( + &mut self, + params: SendAddCreditsNudgeEmailParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/sendAddCreditsNudgeEmail", params) + .await + } + /// Send an `account/read` JSON-RPC request. pub async fn send_get_account_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 20b1729f11..b15960c1e9 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -3,6 +3,8 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -11,6 +13,8 @@ use codex_app_server_protocol::RateLimitReachedType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; @@ -27,6 +31,7 @@ use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const INTERNAL_ERROR_CODE: i64 = -32603; #[tokio::test] async fn get_account_rate_limits_requires_auth() -> Result<()> { @@ -229,6 +234,209 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { Ok(()) } +#[tokio::test] +async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "codex account authentication required to notify workspace owner" + ); + + Ok(()) +} + +#[tokio::test] +async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "chatgpt authentication required to notify workspace owner" + ); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(wiremock::matchers::body_json(json!({ + "credit_type": "usage_limit", + }))) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::CooldownActive); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INTERNAL_ERROR_CODE); + assert!( + error + .error + .message + .contains("failed to notify workspace owner"), + "unexpected error message: {}", + error.error.message + ); + assert_eq!(error.error.data, None); + + Ok(()) +} + async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 84fbffb690..8f84ef28f4 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -20,6 +20,7 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; +use serde::Serialize; use serde::de::DeserializeOwned; use std::fmt; @@ -81,6 +82,18 @@ impl From for RequestError { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize)] +struct SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { /// /api/codex/… @@ -282,6 +295,21 @@ impl Client { Ok(Self::rate_limit_snapshots_from_payload(payload)) } + pub async fn send_add_credits_nudge_email( + &self, + credit_type: AddCreditsNudgeCreditType, + ) -> std::result::Result<(), RequestError> { + let url = self.send_add_credits_nudge_email_url(); + let req = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&SendAddCreditsNudgeEmailRequest { credit_type }); + self.exec_request_detailed(req, "POST", &url).await?; + Ok(()) + } + pub async fn list_tasks( &self, limit: Option, @@ -490,6 +518,21 @@ impl Client { } } + fn send_add_credits_nudge_email_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!( + "{}/api/codex/accounts/send_add_credits_nudge_email", + self.base_url + ), + PathStyle::ChatGptApi => { + format!( + "{}/wham/accounts/send_add_credits_nudge_email", + self.base_url + ) + } + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -767,4 +810,50 @@ mod tests { let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots[0].rate_limit_reached_type, None); } + + #[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(), + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: 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(), + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::ChatGptApi, + }; + assert_eq!( + chatgpt_client.send_add_credits_nudge_email_url(), + "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" + ); + + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .unwrap(), + serde_json::json!({ "credit_type": "credits" }) + ); + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .unwrap(), + serde_json::json!({ "credit_type": "usage_limit" }) + ); + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 397c2a2cd4..300da81568 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -1,6 +1,7 @@ mod client; pub(crate) mod types; +pub use client::AddCreditsNudgeCreditType; pub use client::Client; pub use client::RequestError; pub use types::CodeTaskDetailsResponse; From e9c70fff3f2dac7f3e0fee1abb6abf5b206a4df6 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Fri, 17 Apr 2026 21:44:47 -0700 Subject: [PATCH 13/18] [codex] Add marketplace remove command and shared logic (#17752) ## Summary Move the marketplace remove implementation into shared core logic so both the CLI command and follow-up app-server RPC can reuse the same behavior. This change: - adds a shared `codex_core::plugins::remove_marketplace(...)` flow - moves validation, config removal, and installed-root deletion out of the CLI - keeps the CLI as a thin wrapper over the shared implementation - adds focused core coverage for the shared remove path ## Validation - `just fmt` - focused local coverage for the shared remove path - heavier follow-up validation deferred to stacked PR CI --- codex-rs/cli/src/main.rs | 13 + codex-rs/cli/src/marketplace_cmd.rs | 36 ++ codex-rs/cli/tests/marketplace_remove.rs | 70 ++++ codex-rs/config/src/lib.rs | 3 + codex-rs/config/src/marketplace_edit.rs | 189 +++++++++++ .../core/src/plugins/marketplace_remove.rs | 313 ++++++++++++++++++ codex-rs/core/src/plugins/mod.rs | 5 + 7 files changed, 629 insertions(+) create mode 100644 codex-rs/cli/tests/marketplace_remove.rs create mode 100644 codex-rs/core/src/plugins/marketplace_remove.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 32fb25ad3d..7ed8262fdb 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1729,6 +1729,15 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); } + #[test] + fn plugin_marketplace_remove_parses_under_plugin() { + let cli = + MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "remove", "debug"]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + #[test] fn marketplace_no_longer_parses_at_top_level() { let add_result = @@ -1738,6 +1747,10 @@ mod tests { let upgrade_result = MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]); assert!(upgrade_result.is_err()); + + let remove_result = + MultitoolCli::try_parse_from(["codex", "marketplace", "remove", "debug"]); + assert!(remove_result.is_err()); } fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index ce1f99390a..a29f9ba76f 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -5,9 +5,11 @@ use clap::Parser; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core::plugins::MarketplaceAddRequest; +use codex_core::plugins::MarketplaceRemoveRequest; use codex_core::plugins::PluginMarketplaceUpgradeOutcome; use codex_core::plugins::PluginsManager; use codex_core::plugins::add_marketplace; +use codex_core::plugins::remove_marketplace; use codex_utils_cli::CliConfigOverrides; #[derive(Debug, Parser)] @@ -23,6 +25,7 @@ pub struct MarketplaceCli { enum MarketplaceSubcommand { Add(AddMarketplaceArgs), Upgrade(UpgradeMarketplaceArgs), + Remove(RemoveMarketplaceArgs), } #[derive(Debug, Parser)] @@ -47,6 +50,12 @@ struct UpgradeMarketplaceArgs { marketplace_name: Option, } +#[derive(Debug, Parser)] +struct RemoveMarketplaceArgs { + /// Configured marketplace name to remove. + marketplace_name: String, +} + impl MarketplaceCli { pub async fn run(self) -> Result<()> { let MarketplaceCli { @@ -61,6 +70,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, + MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } Ok(()) @@ -120,6 +130,26 @@ async fn run_upgrade( print_upgrade_outcome(&outcome, marketplace_name.as_deref()) } +async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { + let RemoveMarketplaceArgs { marketplace_name } = args; + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let outcome = remove_marketplace( + codex_home.to_path_buf(), + MarketplaceRemoveRequest { marketplace_name }, + ) + .await?; + + println!("Removed marketplace `{}`.", outcome.marketplace_name); + if let Some(installed_root) = outcome.removed_installed_root { + println!( + "Removed installed marketplace root: {}", + installed_root.as_path().display() + ); + } + + Ok(()) +} + fn print_upgrade_outcome( outcome: &PluginMarketplaceUpgradeOutcome, marketplace_name: Option<&str>, @@ -201,4 +231,10 @@ mod tests { let upgrade_one = UpgradeMarketplaceArgs::try_parse_from(["upgrade", "debug"]).unwrap(); assert_eq!(upgrade_one.marketplace_name.as_deref(), Some("debug")); } + + #[test] + fn remove_subcommand_parses_marketplace_name() { + let remove = RemoveMarketplaceArgs::try_parse_from(["remove", "debug"]).unwrap(); + assert_eq!(remove.marketplace_name, "debug"); + } } diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs new file mode 100644 index 0000000000..06e213bae6 --- /dev/null +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_core::plugins::marketplace_install_root; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn configured_marketplace_update() -> MarketplaceConfigUpdate<'static> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + } +} + +fn write_installed_marketplace(codex_home: &Path, marketplace_name: &str) -> Result<()> { + let root = marketplace_install_root(codex_home).join(marketplace_name); + std::fs::create_dir_all(root.join(".agents/plugins"))?; + std::fs::write(root.join(".agents/plugins/marketplace.json"), "{}")?; + std::fs::write(root.join("marker.txt"), "installed")?; + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .success() + .stdout(contains("Removed marketplace `debug`.")); + + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(config_path)?; + assert!(!config.contains("[marketplaces.debug]")); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .failure() + .stderr(contains( + "marketplace `debug` is not configured or installed", + )); + + Ok(()) +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 18779f9809..c1d9041698 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -63,7 +63,10 @@ pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; pub use marketplace_edit::MarketplaceConfigUpdate; +pub use marketplace_edit::RemoveMarketplaceConfigOutcome; pub use marketplace_edit::record_user_marketplace; +pub use marketplace_edit::remove_user_marketplace; +pub use marketplace_edit::remove_user_marketplace_config; pub use mcp_edit::ConfigEditsBuilder; pub use mcp_edit::load_global_mcp_servers; pub use mcp_types::AppToolApproval; diff --git a/codex-rs/config/src/marketplace_edit.rs b/codex-rs/config/src/marketplace_edit.rs index e20a75a02e..2aad2486eb 100644 --- a/codex-rs/config/src/marketplace_edit.rs +++ b/codex-rs/config/src/marketplace_edit.rs @@ -19,6 +19,13 @@ pub struct MarketplaceConfigUpdate<'a> { pub sparse_paths: &'a [String], } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoveMarketplaceConfigOutcome { + Removed, + NotFound, + NameCaseMismatch { configured_name: String }, +} + pub fn record_user_marketplace( codex_home: &Path, marketplace_name: &str, @@ -31,6 +38,36 @@ pub fn record_user_marketplace( fs::write(config_path, doc.to_string()) } +pub fn remove_user_marketplace(codex_home: &Path, marketplace_name: &str) -> std::io::Result { + let outcome = remove_user_marketplace_config(codex_home, marketplace_name)?; + Ok(outcome == RemoveMarketplaceConfigOutcome::Removed) +} + +pub fn remove_user_marketplace_config( + codex_home: &Path, + marketplace_name: &str, +) -> std::io::Result { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match fs::read_to_string(&config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(RemoveMarketplaceConfigOutcome::NotFound); + } + Err(err) => return Err(err), + }; + + let outcome = remove_marketplace(&mut doc, marketplace_name); + if outcome != RemoveMarketplaceConfigOutcome::Removed { + return Ok(outcome); + } + + fs::create_dir_all(codex_home)?; + fs::write(config_path, doc.to_string())?; + Ok(RemoveMarketplaceConfigOutcome::Removed) +} + fn read_or_create_document(config_path: &Path) -> std::io::Result { match fs::read_to_string(config_path) { Ok(raw) => raw @@ -80,8 +117,160 @@ fn upsert_marketplace( marketplaces.insert(marketplace_name, TomlItem::Table(entry)); } +fn remove_marketplace( + doc: &mut DocumentMut, + marketplace_name: &str, +) -> RemoveMarketplaceConfigOutcome { + let root = doc.as_table_mut(); + let Some(marketplaces_item) = root.get_mut("marketplaces") else { + return RemoveMarketplaceConfigOutcome::NotFound; + }; + + let mut remove_marketplaces = false; + let outcome = match marketplaces_item { + TomlItem::Table(marketplaces) => { + let outcome = if marketplaces.remove(marketplace_name).is_some() { + RemoveMarketplaceConfigOutcome::Removed + } else if let Some(configured_name) = + case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name) + { + RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } + } else { + RemoveMarketplaceConfigOutcome::NotFound + }; + remove_marketplaces = marketplaces.is_empty(); + outcome + } + TomlItem::Value(value) => { + let Some(marketplaces) = value.as_inline_table_mut() else { + return RemoveMarketplaceConfigOutcome::NotFound; + }; + let outcome = if marketplaces.remove(marketplace_name).is_some() { + RemoveMarketplaceConfigOutcome::Removed + } else if let Some(configured_name) = + case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name) + { + RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } + } else { + RemoveMarketplaceConfigOutcome::NotFound + }; + remove_marketplaces = marketplaces.is_empty(); + outcome + } + _ => RemoveMarketplaceConfigOutcome::NotFound, + }; + + if outcome == RemoveMarketplaceConfigOutcome::Removed && remove_marketplaces { + root.remove("marketplaces"); + } + outcome +} + +fn case_mismatched_key<'a>( + mut keys: impl Iterator, + requested_name: &str, +) -> Option { + keys.find(|key| *key != requested_name && key.eq_ignore_ascii_case(requested_name)) + .map(str::to_string) +} + fn new_implicit_table() -> TomlTable { let mut table = TomlTable::new(); table.set_implicit(true); table } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn remove_user_marketplace_removes_requested_entry() { + let codex_home = TempDir::new().unwrap(); + let update = MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update).unwrap(); + record_user_marketplace(codex_home.path(), "other", &update).unwrap(); + + let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap(); + + assert!(removed); + let config: toml::Value = + toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap()) + .unwrap(); + let marketplaces = config + .get("marketplaces") + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!(marketplaces.len(), 1); + assert!(marketplaces.contains_key("other")); + } + + #[test] + fn remove_user_marketplace_returns_false_when_missing() { + let codex_home = TempDir::new().unwrap(); + + let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap(); + + assert!(!removed); + } + + #[test] + fn remove_user_marketplace_config_reports_case_mismatch() { + let codex_home = TempDir::new().unwrap(); + let update = MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update).unwrap(); + + let outcome = remove_user_marketplace_config(codex_home.path(), "Debug").unwrap(); + + assert_eq!( + outcome, + RemoveMarketplaceConfigOutcome::NameCaseMismatch { + configured_name: "debug".to_string() + } + ); + } + + #[test] + fn remove_user_marketplace_config_removes_inline_table_entry() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +marketplaces = { + debug = { source_type = "git", source = "https://github.com/owner/repo.git" }, + other = { source_type = "local", source = "/tmp/marketplace" }, +} +"#, + ) + .unwrap(); + + let outcome = remove_user_marketplace_config(codex_home.path(), "debug").unwrap(); + + assert_eq!(outcome, RemoveMarketplaceConfigOutcome::Removed); + let config: toml::Value = + toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap()) + .unwrap(); + let marketplaces = config + .get("marketplaces") + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!(marketplaces.len(), 1); + assert!(marketplaces.contains_key("other")); + } +} diff --git a/codex-rs/core/src/plugins/marketplace_remove.rs b/codex-rs/core/src/plugins/marketplace_remove.rs new file mode 100644 index 0000000000..490b16f95d --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_remove.rs @@ -0,0 +1,313 @@ +use crate::plugins::marketplace_install_root; +use crate::plugins::validate_plugin_segment; +use codex_config::RemoveMarketplaceConfigOutcome; +use codex_config::remove_user_marketplace_config; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceRemoveRequest { + pub marketplace_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceRemoveOutcome { + pub marketplace_name: String, + pub removed_installed_root: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum MarketplaceRemoveError { + #[error("{0}")] + InvalidRequest(String), + #[error("{0}")] + Internal(String), +} + +pub async fn remove_marketplace( + codex_home: PathBuf, + request: MarketplaceRemoveRequest, +) -> Result { + tokio::task::spawn_blocking(move || remove_marketplace_sync(codex_home.as_path(), request)) + .await + .map_err(|err| { + MarketplaceRemoveError::Internal(format!("failed to remove marketplace: {err}")) + })? +} + +fn remove_marketplace_sync( + codex_home: &Path, + request: MarketplaceRemoveRequest, +) -> Result { + let marketplace_name = request.marketplace_name; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(MarketplaceRemoveError::InvalidRequest)?; + + let destination = marketplace_install_root(codex_home).join(&marketplace_name); + let config_outcome = + remove_user_marketplace_config(codex_home, &marketplace_name).map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to remove marketplace '{marketplace_name}' from user config.toml: {err}" + )) + })?; + if let RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } = &config_outcome { + return Err(MarketplaceRemoveError::InvalidRequest(format!( + "marketplace `{marketplace_name}` does not match configured marketplace `{configured_name}` exactly" + ))); + } + + let removed_config = config_outcome == RemoveMarketplaceConfigOutcome::Removed; + let removed_installed_root = remove_marketplace_root(&destination)?; + + if removed_installed_root.is_none() && !removed_config { + return Err(MarketplaceRemoveError::InvalidRequest(format!( + "marketplace `{marketplace_name}` is not configured or installed" + ))); + } + + Ok(MarketplaceRemoveOutcome { + marketplace_name, + removed_installed_root, + }) +} + +fn remove_marketplace_root(root: &Path) -> Result, MarketplaceRemoveError> { + if !root.exists() { + return Ok(None); + } + + let removed_root = AbsolutePathBuf::try_from(root.to_path_buf()).map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to resolve installed marketplace root {}: {err}", + root.display() + )) + })?; + let metadata = fs::symlink_metadata(root).map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to inspect installed marketplace root {}: {err}", + root.display() + )) + })?; + let remove_result = if metadata.is_dir() { + fs::remove_dir_all(root) + } else { + fs::remove_file(root) + }; + remove_result.map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to remove installed marketplace root {}: {err}", + root.display() + )) + })?; + Ok(Some(removed_root)) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_config::MarketplaceConfigUpdate; + use codex_config::record_user_marketplace; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn remove_marketplace_sync_removes_config_and_installed_root() { + let codex_home = TempDir::new().unwrap(); + record_user_marketplace( + codex_home.path(), + "debug", + &MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(installed_root.join(".agents/plugins")).unwrap(); + fs::write( + installed_root.join(".agents/plugins/marketplace.json"), + "{}", + ) + .unwrap(); + + let outcome = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap(); + + assert_eq!(outcome.marketplace_name, "debug"); + assert_eq!( + outcome.removed_installed_root, + Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap()) + ); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains("[marketplaces.debug]")); + assert!(!installed_root.exists()); + } + + #[test] + fn remove_marketplace_sync_rejects_unknown_marketplace() { + let codex_home = TempDir::new().unwrap(); + + let err = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "marketplace `debug` is not configured or installed" + ); + } + + #[test] + fn remove_marketplace_sync_rejects_case_mismatched_configured_name() { + let codex_home = TempDir::new().unwrap(); + record_user_marketplace( + codex_home.path(), + "debug", + &MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(&installed_root).unwrap(); + + let err = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "Debug".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "marketplace `Debug` does not match configured marketplace `debug` exactly" + ); + assert!(installed_root.exists()); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains("[marketplaces.debug]")); + } + + #[test] + fn remove_marketplace_sync_keeps_installed_root_when_config_removal_fails() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(codex_config::CONFIG_TOML_FILE), + "[marketplaces.debug\n", + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(&installed_root).unwrap(); + + let err = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("failed to remove marketplace 'debug' from user config.toml") + ); + assert!(installed_root.exists()); + } + + #[test] + fn remove_marketplace_sync_removes_file_installed_root() { + let codex_home = TempDir::new().unwrap(); + record_user_marketplace( + codex_home.path(), + "debug", + &MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(installed_root.parent().unwrap()).unwrap(); + fs::write(&installed_root, "corrupt install root").unwrap(); + + let outcome = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap(); + + assert_eq!( + outcome, + MarketplaceRemoveOutcome { + marketplace_name: "debug".to_string(), + removed_installed_root: Some( + AbsolutePathBuf::try_from(installed_root.clone()).unwrap() + ), + } + ); + assert!(!installed_root.exists()); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains("[marketplaces.debug]")); + } + + #[test] + fn remove_marketplace_sync_removes_inline_config_entry() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(codex_config::CONFIG_TOML_FILE), + r#" +marketplaces = { debug = { source_type = "git", source = "https://github.com/owner/repo.git" } } +"#, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(&installed_root).unwrap(); + + let outcome = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap(); + + assert_eq!(outcome.marketplace_name, "debug"); + assert_eq!( + outcome.removed_installed_root, + Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap()) + ); + assert!(!installed_root.exists()); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains("debug")); + } +} diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 97b4db9dff..5806016b9a 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -5,6 +5,7 @@ mod injection; mod installed_marketplaces; mod manager; mod marketplace_add; +mod marketplace_remove; mod mentions; mod render; mod startup_sync; @@ -52,6 +53,10 @@ pub use marketplace_add::MarketplaceAddRequest; pub use marketplace_add::add_marketplace; pub(crate) use marketplace_add::is_local_marketplace_source; pub(crate) use marketplace_add::parse_marketplace_source; +pub use marketplace_remove::MarketplaceRemoveError; +pub use marketplace_remove::MarketplaceRemoveOutcome; +pub use marketplace_remove::MarketplaceRemoveRequest; +pub use marketplace_remove::remove_marketplace; pub(crate) use render::render_explicit_plugin_instructions; pub(crate) use render::render_plugins_section; pub(crate) use startup_sync::curated_plugins_repo_path; From 5bb193aa88fef0f5ef3fbbd2c6253ba93d3f6521 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 17 Apr 2026 21:48:14 -0700 Subject: [PATCH 14/18] Add max context window model metadata (#18382) Adds max_context_window to model metadata and routes core context-window reads through resolved model info. Config model_context_window overrides are clamped to max_context_window when present; without an override, the model context_window is used. --- .../app-server/tests/common/models_cache.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/memories/prompts.rs | 2 +- codex-rs/core/src/memories/prompts_tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 8 +- 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 | 230 ++++++++++++++++++ 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/models.json | 13 + codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/models-manager/src/model_info.rs | 9 +- .../models-manager/src/model_info_tests.rs | 29 +++ codex-rs/protocol/src/openai_models.rs | 33 ++- 17 files changed, 330 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index f38b6fd992..557fa56204 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -43,6 +43,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 96de0b2cec..9f95c9441f 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -89,6 +89,7 @@ async fn models_client_hits_models_endpoint() { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index fc85917c9b..9425a53804 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -239,7 +239,7 @@ pub(super) fn build_stage_one_input_message( rollout_contents: &str, ) -> anyhow::Result { let rollout_token_limit = model_info - .context_window + .resolved_context_window() .and_then(|limit| (limit > 0).then_some(limit)) .map(|limit| limit.saturating_mul(model_info.effective_context_window_percent) / 100) .map(|limit| (limit.saturating_mul(phase_one::CONTEXT_WINDOW_PERCENT) / 100).max(1)) diff --git a/codex-rs/core/src/memories/prompts_tests.rs b/codex-rs/core/src/memories/prompts_tests.rs index 959bb6911d..d0ce447c4a 100644 --- a/codex-rs/core/src/memories/prompts_tests.rs +++ b/codex-rs/core/src/memories/prompts_tests.rs @@ -41,6 +41,7 @@ fn build_stage_one_input_message_uses_default_limit_when_model_context_window_mi let input = format!("{}{}{}", "a".repeat(700_000), "middle", "z".repeat(700_000)); let mut model_info = model_info_from_slug("gpt-5.2-codex"); model_info.context_window = None; + model_info.max_context_window = None; let expected_truncated = truncate_text( &input, TruncationPolicy::Tokens(phase_one::DEFAULT_STAGE_ONE_ROLLOUT_TOKEN_LIMIT), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index f647c64956..dd86804ee5 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -75,9 +75,11 @@ pub(crate) struct TurnContext { impl TurnContext { pub(crate) fn model_context_window(&self) -> Option { let effective_context_window_percent = self.model_info.effective_context_window_percent; - self.model_info.context_window.map(|context_window| { - context_window.saturating_mul(effective_context_window_percent) / 100 - }) + self.model_info + .resolved_context_window() + .map(|context_window| { + context_window.saturating_mul(effective_context_window_percent) / 100 + }) } pub(crate) fn apps_enabled(&self) -> bool { diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 2b50a755e5..10b97721aa 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -97,6 +97,7 @@ fn test_model_info( supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), @@ -914,6 +915,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(large_context_window), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent, experimental_supported_tools: 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 9dfc32fc73..bb0ecc85f9 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -350,6 +350,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 2bcd074189..53cb8f9bf7 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -666,6 +666,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(128_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), @@ -783,6 +784,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(128_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 80e725ab8a..6feb18a2e3 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -116,6 +116,233 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( Ok(()) } +/// Scenario: the model advertises a default 273k context window and a 400k max +/// context window, and the user explicitly configures 1M. This verifies the +/// runtime turn clamps the override to the advertised max window. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_config_context_window_override_clamps_to_max_context_window() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let requested_model = "gpt-5.4-test"; + let mut remote_model = + test_remote_model("gpt-5.4", ModelVisibility::List, /*priority*/ 1_000); + remote_model.context_window = Some(273_000); + remote_model.max_context_window = Some(400_000); + remote_model.effective_context_window_percent = 100; + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let TestCodex { + codex, cwd, config, .. + } = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model = Some(requested_model.to_string()); + config.model_context_window = Some(1_000_000); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "check context window".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: requested_model.to_string(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let turn_started_event = wait_for_event(&codex, |event| { + matches!( + event, + EventMsg::TurnStarted(started) + if started.model_context_window == Some(400_000) + ) + }) + .await; + let EventMsg::TurnStarted(turn_started) = turn_started_event else { + unreachable!("wait_for_event returned unexpected event"); + }; + + assert_eq!(turn_started.model_context_window, Some(400_000)); + + Ok(()) +} + +/// Scenario: the user explicitly configures a context window above the model's +/// max_context_window. This verifies the runtime window is clamped to the max +/// instead of using the oversized config value. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_config_override_above_max_uses_max_context_window() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let requested_model = "gpt-5.4-test"; + let mut remote_model = + test_remote_model("gpt-5.4", ModelVisibility::List, /*priority*/ 1_000); + remote_model.context_window = Some(273_000); + remote_model.max_context_window = Some(400_000); + remote_model.effective_context_window_percent = 100; + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let TestCodex { + codex, cwd, config, .. + } = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model = Some(requested_model.to_string()); + config.model_context_window = Some(500_000); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "check context window".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: requested_model.to_string(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let turn_started_event = wait_for_event(&codex, |event| { + matches!( + event, + EventMsg::TurnStarted(started) + if started.model_context_window == Some(400_000) + ) + }) + .await; + let EventMsg::TurnStarted(turn_started) = turn_started_event else { + unreachable!("wait_for_event returned unexpected event"); + }; + + assert_eq!(turn_started.model_context_window, Some(400_000)); + + Ok(()) +} + +/// Scenario: model metadata includes both context_window and max_context_window, +/// but the user did not configure an override. This verifies the runtime keeps +/// using the model's default context_window in the no-override path. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_use_context_window_when_config_override_is_absent() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let requested_model = "gpt-5.4-test"; + let mut remote_model = + test_remote_model("gpt-5.4", ModelVisibility::List, /*priority*/ 1_000); + remote_model.context_window = Some(273_000); + remote_model.max_context_window = Some(400_000); + remote_model.effective_context_window_percent = 100; + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let TestCodex { + codex, cwd, config, .. + } = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model = Some(requested_model.to_string()); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "check context window".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: requested_model.to_string(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let turn_started_event = wait_for_event(&codex, |event| { + matches!( + event, + EventMsg::TurnStarted(started) + if started.model_context_window == Some(273_000) + ) + }) + .await; + let EventMsg::TurnStarted(turn_started) = turn_started_event else { + unreachable!("wait_for_event returned unexpected event"); + }; + + assert_eq!(turn_started.model_context_window, Some(273_000)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<()> { skip_if_no_network!(Ok(())); @@ -312,6 +539,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), @@ -561,6 +789,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), @@ -1044,6 +1273,7 @@ fn test_remote_model_with_policy( supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 8c7e7133a3..733edf0632 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1020,6 +1020,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 8c131b16bc..1d2bd1ed05 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -82,6 +82,7 @@ fn test_model_info( supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 47254ab177..891c4d2381 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1365,6 +1365,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/models-manager/models.json b/codex-rs/models-manager/models.json index 90b53216a6..fd089d8991 100644 --- a/codex-rs/models-manager/models.json +++ b/codex-rs/models-manager/models.json @@ -15,6 +15,7 @@ }, "supports_parallel_tool_calls": true, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "none", "slug": "gpt-5.3-codex", @@ -88,6 +89,7 @@ }, "supports_parallel_tool_calls": true, "context_window": 272000, + "max_context_window": 1000000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "none", "slug": "gpt-5.4", @@ -161,6 +163,7 @@ }, "supports_parallel_tool_calls": true, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "auto", "slug": "gpt-5.2-codex", @@ -235,6 +238,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "auto", "slug": "gpt-5.1-codex-max", @@ -302,6 +306,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "auto", "slug": "gpt-5.1-codex", @@ -365,6 +370,7 @@ }, "supports_parallel_tool_calls": true, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "none", "default_reasoning_summary": "auto", "slug": "gpt-5.2", @@ -432,6 +438,7 @@ }, "supports_parallel_tool_calls": true, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "none", "default_reasoning_summary": "auto", "slug": "gpt-5.1", @@ -495,6 +502,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "auto", "slug": "gpt-5-codex", @@ -558,6 +566,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "none", "default_reasoning_summary": "auto", "slug": "gpt-5", @@ -624,6 +633,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 128000, + "max_context_window": 128000, "reasoning_summary_format": "none", "default_reasoning_summary": "auto", "slug": "gpt-oss-120b", @@ -683,6 +693,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 128000, + "max_context_window": 128000, "reasoning_summary_format": "none", "default_reasoning_summary": "auto", "slug": "gpt-oss-20b", @@ -743,6 +754,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "auto", "slug": "gpt-5.1-codex-mini", @@ -802,6 +814,7 @@ }, "supports_parallel_tool_calls": false, "context_window": 272000, + "max_context_window": 272000, "reasoning_summary_format": "experimental", "default_reasoning_summary": "auto", "slug": "gpt-5-codex-mini", diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 23de81781a..a70f46567e 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -70,6 +70,7 @@ fn remote_model_with_visibility( "supports_parallel_tool_calls": false, "supports_image_detail_original": false, "context_window": 272_000, + "max_context_window": 272_000, "experimental_supported_tools": [], })) .expect("valid model") diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index fefec50ce7..8e8abae549 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -27,7 +27,13 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) model.supports_reasoning_summaries = true; } if let Some(context_window) = config.model_context_window { - model.context_window = Some(context_window); + model.context_window = Some( + model + .max_context_window + .map_or(context_window, |max_context_window| { + context_window.min(max_context_window) + }), + ); } if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit { model.auto_compact_token_limit = Some(auto_compact_token_limit); @@ -84,6 +90,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), + max_context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index ec5b6ed85c..70ad3da8df 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -43,3 +43,32 @@ fn reasoning_summaries_override_false_is_noop_when_model_is_false() { assert_eq!(updated, model); } + +#[test] +fn model_context_window_override_clamps_to_max_context_window() { + let mut model = model_info_from_slug("unknown-model"); + model.context_window = Some(273_000); + model.max_context_window = Some(400_000); + let config = ModelsManagerConfig { + model_context_window: Some(500_000), + ..Default::default() + }; + + let updated = with_config_overrides(model.clone(), &config); + let mut expected = model; + expected.context_window = Some(400_000); + + assert_eq!(updated, expected); +} + +#[test] +fn model_context_window_uses_model_value_without_override() { + let mut model = model_info_from_slug("unknown-model"); + model.context_window = Some(273_000); + model.max_context_window = Some(400_000); + let config = ModelsManagerConfig::default(); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); +} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 3339e674be..41275e6a6b 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -277,6 +277,9 @@ pub struct ModelInfo { pub supports_image_detail_original: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub context_window: Option, + /// Maximum context window allowed for config overrides. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_context_window: Option, /// Token threshold for automatic compaction. When omitted, core derives it /// from `context_window` (90%). When provided, core clamps it to 90% of the /// context window when available. @@ -300,9 +303,13 @@ pub struct ModelInfo { } impl ModelInfo { + pub fn resolved_context_window(&self) -> Option { + self.context_window.or(self.max_context_window) + } + pub fn auto_compact_token_limit(&self) -> Option { let context_limit = self - .context_window + .resolved_context_window() .map(|context_window| (context_window * 9) / 10); let config_limit = self.auto_compact_token_limit; if let Some(context_limit) = context_limit { @@ -555,6 +562,7 @@ mod tests { supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: None, + max_context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, experimental_supported_tools: vec![], @@ -780,6 +788,29 @@ mod tests { assert!(!model.supports_search_tool); } + #[test] + fn resolved_context_window_prefers_context_window() { + let model = ModelInfo { + context_window: Some(273_000), + max_context_window: Some(400_000), + ..test_model(/*spec*/ None) + }; + + assert_eq!(model.resolved_context_window(), Some(273_000)); + } + + #[test] + fn resolved_context_window_falls_back_to_max_context_window() { + let model = ModelInfo { + context_window: None, + max_context_window: Some(400_000), + ..test_model(/*spec*/ None) + }; + + assert_eq!(model.resolved_context_window(), Some(400_000)); + assert_eq!(model.auto_compact_token_limit(), Some(360_000)); + } + #[test] fn model_preset_preserves_availability_nux() { let preset = ModelPreset::from(ModelInfo { From e3c2acb9cdd37277c334765a30ea2ac6020a2b9f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sat, 18 Apr 2026 17:53:48 +0100 Subject: [PATCH 15/18] Revert "[codex] drain mailbox only at request boundaries" (#18325) ## Summary - Reverts PR #17749 so queued inter-agent mail can again preempt after reasoning/commentary output item boundaries. - Applies the revert to the current `codex/turn.rs` module layout and restores the prior pending-input test expectations/snapshots. ## Testing - `just fmt` - `cargo test -p codex-core --test all pending_input` - `cargo test -p codex-core` failed in unrelated `tools::js_repl::tests::js_repl_imported_local_files_can_access_repl_globals`: dotslash download hit `mktemp: mkdtemp failed ... Operation not permitted` in the sandbox temp dir. Co-authored-by: Codex --- codex-rs/core/src/session/turn.rs | 27 +++++++++++++ codex-rs/core/tests/suite/pending_input.rs | 39 ++++++++++--------- ...ng_input_queued_mail_after_commentary.snap | 6 +-- ...ing_input_queued_mail_after_reasoning.snap | 3 +- 4 files changed, 51 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index ae7088c603..33c8402a05 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -78,6 +78,7 @@ use codex_protocol::items::UserMessageItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; +use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageContentDeltaEvent; @@ -1944,6 +1945,25 @@ async fn try_run_sampling_request( cancellation_token: cancellation_token.child_token(), }; + let preempt_for_mailbox_mail = match &item { + ResponseItem::Message { role, phase, .. } => { + role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) + } + ResponseItem::Reasoning { .. } => true, + ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::Other => false, + }; + let output_result = match handle_output_item_done(&mut ctx, item, previously_active_item) .instrument(handle_responses) @@ -1959,6 +1979,13 @@ async fn try_run_sampling_request( last_agent_message = Some(agent_message); } needs_follow_up |= output_result.needs_follow_up; + // todo: remove before stabilizing multi-agent v2 + if preempt_for_mailbox_mail && sess.mailbox_rx.lock().await.has_pending() { + break Ok(SamplingRequestResult { + needs_follow_up: true, + last_agent_message, + }); + } } ResponseEvent::OutputItemAdded(item) => { if let ResponseItem::CustomToolCall { call_id, name, .. } = &item { diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 2376da3d6c..6426059b12 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -321,7 +321,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn queued_inter_agent_mail_waits_for_request_boundary_after_reasoning_item() { +async fn queued_inter_agent_mail_triggers_follow_up_after_reasoning_item() { let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel(); let first_chunks = vec![ @@ -331,18 +331,14 @@ async fn queued_inter_agent_mail_waits_for_request_boundary_after_reasoning_item gate_reasoning_done_rx, vec![ ev_reasoning_item("reason-1", &["thinking"], &[]), - ev_message_item_added("msg-preserved", ""), - ev_output_text_delta("preserved commentary"), - json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "id": "msg-preserved", - "content": [{"type": "output_text", "text": "preserved commentary"}], - "phase": "commentary", - } - }), + ev_function_call( + "call-stale", + "shell", + r#"{"command":"echo stale tool call"}"#, + ), + ev_message_item_added("msg-stale", ""), + ev_output_text_delta("stale final"), + ev_message_item_done("msg-stale", "stale final"), ev_completed("resp-1"), ], ), @@ -370,7 +366,7 @@ async fn queued_inter_agent_mail_waits_for_request_boundary_after_reasoning_item } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn queued_inter_agent_mail_waits_for_request_boundary_after_commentary_message_item() { +async fn queued_inter_agent_mail_triggers_follow_up_after_commentary_message_item() { let (gate_message_done_tx, gate_message_done_rx) = oneshot::channel(); let first_chunks = vec![ @@ -379,18 +375,25 @@ async fn queued_inter_agent_mail_waits_for_request_boundary_after_commentary_mes gated_chunk( gate_message_done_rx, vec![ - ev_output_text_delta("first commentary"), + ev_output_text_delta("first answer"), json!({ "type": "response.output_item.done", "item": { "type": "message", "role": "assistant", "id": "msg-1", - "content": [{"type": "output_text", "text": "first commentary"}], + "content": [{"type": "output_text", "text": "first answer"}], "phase": "commentary", } }), - ev_function_call("call-preserved", "test_tool", "{}"), + ev_function_call( + "call-stale", + "shell", + r#"{"command":"echo stale tool call"}"#, + ), + ev_message_item_added("msg-stale", ""), + ev_output_text_delta("stale final"), + ev_message_item_done("msg-stale", "stale final"), ev_completed("resp-1"), ], ), @@ -416,7 +419,7 @@ async fn queued_inter_agent_mail_waits_for_request_boundary_after_commentary_mes let _ = gate_message_done_tx.send(()); - wait_for_agent_message(&codex, "first commentary").await; + wait_for_agent_message(&codex, "first answer").await; wait_for_turn_complete(&codex).await; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_commentary.snap b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_commentary.snap index d3889fcd32..e65a5f34f0 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_commentary.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_commentary.snap @@ -13,7 +13,5 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot 00:message/developer: 01:message/user:> 02:message/user:first prompt -03:message/assistant:first commentary -04:function_call/test_tool -05:function_call_output:unsupported call: test_tool -06:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false} +03:message/assistant:first answer +04:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_reasoning.snap b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_reasoning.snap index 8e23dfe52d..004196f97a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_reasoning.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_queued_mail_after_reasoning.snap @@ -14,5 +14,4 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot 01:message/user:> 02:message/user:first prompt 03:reasoning:summary=thinking:encrypted=true -04:message/assistant:preserved commentary -05:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false} +04:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false} From 53b15703678f7cf222d1619c8690145454e576a2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 18 Apr 2026 11:01:12 -0700 Subject: [PATCH 16/18] Update image outputs to default to high detail (#18386) Do not assume the default `detail`. --- .../schema/json/ClientRequest.json | 10 +++ .../codex_app_server_protocol.schemas.json | 10 +++ .../codex_app_server_protocol.v2.schemas.json | 10 +++ .../RawResponseItemCompletedNotification.json | 10 +++ .../schema/json/v2/ThreadResumeParams.json | 10 +++ .../schema/typescript/ContentItem.ts | 3 +- .../tests/suite/v2/dynamic_tools.rs | 6 +- codex-rs/code-mode/src/description.rs | 2 +- codex-rs/code-mode/src/lib.rs | 1 + codex-rs/code-mode/src/response.rs | 2 + codex-rs/code-mode/src/runtime/value.rs | 5 +- codex-rs/code-mode/src/service.rs | 2 +- codex-rs/core/src/compact_tests.rs | 2 + codex-rs/core/src/context_manager/history.rs | 4 +- .../core/src/context_manager/history_tests.rs | 32 +++++++--- codex-rs/core/src/event_mapping.rs | 2 +- codex-rs/core/src/event_mapping_tests.rs | 5 ++ .../src/tools/code_mode/response_adapter.rs | 5 +- codex-rs/core/src/tools/context.rs | 5 +- codex-rs/core/src/tools/context_tests.rs | 7 +- .../core/src/tools/handlers/view_image.rs | 11 +++- codex-rs/core/src/tools/js_repl/kernel.js | 15 +++-- codex-rs/core/src/tools/js_repl/mod.rs | 4 +- codex-rs/core/src/tools/js_repl/mod_tests.rs | 36 +++++------ codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 6 +- codex-rs/core/tests/suite/image_rollout.rs | 13 +++- codex-rs/core/tests/suite/rmcp_client.rs | 3 +- ..._compaction_including_incoming_shapes.snap | 2 +- codex-rs/core/tests/suite/truncation.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 15 ++++- codex-rs/protocol/src/models.rs | 64 +++++++++++++------ codex-rs/tools/src/image_detail.rs | 6 +- codex-rs/tools/src/image_detail_tests.rs | 13 ++-- .../output-truncation/src/truncate_tests.rs | 13 ++-- 35 files changed, 245 insertions(+), 93 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3c6a8c4678..5aae5fd1d9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -471,6 +471,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "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 6ce0d2190b..bf8dbb166d 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 @@ -7173,6 +7173,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "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 74cde880e0..59b830d4f2 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 @@ -3750,6 +3750,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, 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 2b0c66da42..956e3b2507 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -25,6 +25,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, 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 3c8eb552ae..35a9e5b2a8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -83,6 +83,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ContentItem.ts b/codex-rs/app-server-protocol/schema/typescript/ContentItem.ts index c89b9d78a4..21cd8d02f3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ContentItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ContentItem.ts @@ -1,5 +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 { ImageDetail } from "./ImageDetail"; -export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, } | { "type": "output_text", text: string, }; +export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "output_text", text: string, }; diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 0a3315a07a..50dd071d84 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -477,7 +478,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( DynamicToolCallOutputContentItem::InputImage { image_url } => { FunctionCallOutputContentItem::InputImage { image_url, - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), } } }) @@ -535,7 +536,8 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( }, { "type": "input_image", - "image_url": "data:image/png;base64,AAA" + "image_url": "data:image/png;base64,AAA", + "detail": "high" } ]) ); diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 7f5e47fa25..4c5eb6fbdc 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -26,7 +26,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Global helpers: - `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 original detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `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. - `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/lib.rs b/codex-rs/code-mode/src/lib.rs index 880e84ef4a..bb27d99960 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -15,6 +15,7 @@ pub use description::normalize_code_mode_identifier; pub use description::parse_exec_source; pub use description::render_code_mode_sample; pub use description::render_json_schema_to_typescript; +pub use response::DEFAULT_IMAGE_DETAIL; pub use response::FunctionCallOutputContentItem; pub use response::ImageDetail; pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs index 43579fac85..0ac3a03770 100644 --- a/codex-rs/code-mode/src/response.rs +++ b/codex-rs/code-mode/src/response.rs @@ -10,6 +10,8 @@ pub enum ImageDetail { Original, } +pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum FunctionCallOutputContentItem { diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs index 5c63434f4f..8d76a832d3 100644 --- a/codex-rs/code-mode/src/runtime/value.rs +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -1,5 +1,6 @@ use serde_json::Value as JsonValue; +use crate::response::DEFAULT_IMAGE_DETAIL; use crate::response::FunctionCallOutputContentItem; use crate::response::ImageDetail; @@ -81,7 +82,7 @@ pub(super) fn normalize_output_image( } }) } - None => None, + None => Some(DEFAULT_IMAGE_DETAIL), }; Ok(FunctionCallOutputContentItem::InputImage { image_url, detail }) @@ -159,7 +160,7 @@ fn parse_mcp_output_image( .and_then(JsonValue::as_object) .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(JsonValue::as_str) - .filter(|detail| *detail == "original") + .filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original")) .map(str::to_string); Ok((image_url, detail)) } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 79ca010c1a..4a46d36b41 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -669,7 +669,7 @@ text(JSON.stringify(returnsUndefined)); }, FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/image.jpg".to_string(), - detail: None, + detail: Some(crate::DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "[true,true,true]".to_string(), diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 6c482defa1..ed2fd8487d 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use pretty_assertions::assert_eq; async fn process_compacted_history_with_test_session( @@ -45,6 +46,7 @@ fn content_items_to_text_joins_non_empty_segments() { fn content_items_to_text_ignores_image_only_content() { let items = vec![ContentItem::InputImage { image_url: "file://image.png".to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }]; let joined = content_items_to_text(&items); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index db2c6b58b1..c4bdc916ff 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -649,8 +649,8 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { match item { ResponseItem::Message { content, .. } => { for content_item in content { - if let ContentItem::InputImage { image_url } = content_item { - accumulate(image_url, None); + if let ContentItem::InputImage { image_url, detail } = content_item { + accumulate(image_url, *detail); } } } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index ff71b797be..1df14ca8bc 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -6,6 +6,7 @@ use codex_protocol::AgentPath; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -382,6 +383,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ContentItem::InputImage { image_url: "https://example.com/img.png".to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }, ContentItem::InputText { text: "caption".to_string(), @@ -405,7 +407,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/result.png".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }, @@ -425,7 +427,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/js-repl-result.png".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }, @@ -506,6 +508,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ContentItem::InputImage { image_url: "https://example.com/img.png".to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }, ], end_turn: None, @@ -715,7 +718,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), success: Some(true), @@ -752,6 +755,7 @@ fn replace_last_turn_images_does_not_touch_user_images() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }], end_turn: None, phase: None, @@ -1680,7 +1684,10 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { ContentItem::InputText { text: "Here is the screenshot".to_string(), }, - ContentItem::InputImage { image_url }, + ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ], end_turn: None, phase: None, @@ -1717,7 +1724,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { }, FunctionCallOutputContentItem::InputImage { image_url, - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -1743,7 +1750,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { }, FunctionCallOutputContentItem::InputImage { image_url, - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -1763,6 +1770,7 @@ fn non_base64_image_urls_are_unchanged() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url: "https://example.com/foo.png".to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }], end_turn: None, phase: None, @@ -1772,7 +1780,7 @@ fn non_base64_image_urls_are_unchanged() { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "file:///tmp/foo.png".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -1794,6 +1802,7 @@ fn data_url_without_base64_marker_is_unchanged() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url: "data:image/svg+xml,".to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }], end_turn: None, phase: None, @@ -1814,7 +1823,7 @@ fn non_image_base64_data_url_is_unchanged() { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url, - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -1832,7 +1841,10 @@ fn mixed_case_data_url_markers_are_adjusted() { let item = ResponseItem::Message { id: None, role: "user".to_string(), - content: vec![ContentItem::InputImage { image_url }], + content: vec![ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }], end_turn: None, phase: None, }; @@ -1859,9 +1871,11 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { }, ContentItem::InputImage { image_url: image_url_one, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ContentItem::InputImage { image_url: image_url_two, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ], end_turn: None, diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 5e174944f1..21e13f6c15 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -90,7 +90,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { text_elements: Vec::new(), }); } - ContentItem::InputImage { image_url } => { + ContentItem::InputImage { image_url, .. } => { content.push(UserInput::Image { image_url: image_url.clone(), }); diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index a06111fd9a..0cadc5fbda 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -5,6 +5,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; @@ -26,9 +27,11 @@ fn parses_user_message_with_text_and_two_images() { }, ContentItem::InputImage { image_url: img1.clone(), + detail: Some(DEFAULT_IMAGE_DETAIL), }, ContentItem::InputImage { image_url: img2.clone(), + detail: Some(DEFAULT_IMAGE_DETAIL), }, ], end_turn: None, @@ -66,6 +69,7 @@ fn skips_local_image_label_text() { ContentItem::InputText { text: label }, ContentItem::InputImage { image_url: image_url.clone(), + detail: Some(DEFAULT_IMAGE_DETAIL), }, ContentItem::InputText { text: "".to_string(), @@ -145,6 +149,7 @@ fn skips_unnamed_image_label_text() { ContentItem::InputText { text: label }, ContentItem::InputImage { image_url: image_url.clone(), + detail: Some(DEFAULT_IMAGE_DETAIL), }, ContentItem::InputText { text: codex_protocol::models::image_close_tag_text(), diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs index b90448acf9..e20cf6a071 100644 --- a/codex-rs/core/src/tools/code_mode/response_adapter.rs +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -1,4 +1,5 @@ use codex_code_mode::ImageDetail as CodeModeImageDetail; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ImageDetail; @@ -36,7 +37,9 @@ impl IntoProtocol codex_code_mode::FunctionCallOutputContentItem::InputImage { image_url, detail } => { FunctionCallOutputContentItem::InputImage { image_url, - detail: detail.map(IntoProtocol::into_protocol), + detail: detail + .map(IntoProtocol::into_protocol) + .or(Some(DEFAULT_IMAGE_DETAIL)), } } } diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index a2f3a7f7c6..4e144b5507 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -7,6 +7,7 @@ use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::resolve_max_tokens; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -463,10 +464,10 @@ pub(crate) fn response_input_to_code_mode_result(response: ResponseInputItem) -> | codex_protocol::models::ContentItem::OutputText { text } => { FunctionCallOutputContentItem::InputText { text } } - codex_protocol::models::ContentItem::InputImage { image_url } => { + codex_protocol::models::ContentItem::InputImage { image_url, detail } => { FunctionCallOutputContentItem::InputImage { image_url, - detail: None, + detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)), } } }) diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 8df9159edd..c62328dff5 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use core_test_support::assert_regex_match; use pretty_assertions::assert_eq; use serde_json::json; @@ -173,7 +174,7 @@ fn mcp_tool_output_response_item_preserves_content_items() { }, FunctionCallOutputContentItem::InputImage { image_url: image_url.to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ] .as_slice() @@ -239,7 +240,7 @@ fn custom_tool_calls_can_derive_text_from_content_items() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), @@ -259,7 +260,7 @@ fn custom_tool_calls_can_derive_text_from_content_items() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 33ce2054ac..8f3f69701f 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,3 +1,4 @@ +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -133,7 +134,11 @@ impl ToolHandler for ViewImageHandler { } else { PromptImageMode::ResizeToFit }; - let image_detail = use_original_detail.then_some(ImageDetail::Original); + let image_detail = Some(if use_original_detail { + ImageDetail::Original + } else { + DEFAULT_IMAGE_DETAIL + }); let image = load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| { @@ -210,7 +215,7 @@ mod tests { fn code_mode_result_returns_image_url_object() { let output = ViewImageOutput { image_url: "data:image/png;base64,AAA".to_string(), - image_detail: None, + image_detail: Some(DEFAULT_IMAGE_DETAIL), }; let result = output.code_mode_result(&ToolPayload::Function { @@ -221,7 +226,7 @@ mod tests { result, json!({ "image_url": "data:image/png;base64,AAA", - "detail": null, + "detail": "high", }) ); } diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index 3e5cf855f7..3eb3e916ce 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -1225,9 +1225,9 @@ function parseImageDetail(detail) { if (typeof detail !== "string" || !detail) { throw new Error("codex.emitImage expected detail to be a non-empty string"); } - if (detail !== "original") { + if (!["auto", "low", "high", "original"].includes(detail)) { throw new Error( - 'codex.emitImage only supports detail "original"; omit detail for default behavior', + 'codex.emitImage expected detail to be one of "auto", "low", "high", or "original"', ); } return detail; @@ -1331,10 +1331,17 @@ function normalizeMcpImageData(data, mimeType) { } function parseMcpImageDetail(meta) { - if (!isPlainObject(meta) || meta["codex/imageDetail"] !== "original") { + if (!isPlainObject(meta)) { return undefined; } - return "original"; + const detail = meta["codex/imageDetail"]; + if ( + typeof detail !== "string" || + !["auto", "low", "high", "original"].includes(detail) + ) { + return undefined; + } + return detail; } function parseMcpToolResult(result) { diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index deaab9c2f3..23f4906e5f 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -10,6 +10,7 @@ use std::time::Duration; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; @@ -1750,7 +1751,8 @@ fn emitted_image_content_item( ) -> FunctionCallOutputContentItem { FunctionCallOutputContentItem::InputImage { image_url, - detail: normalize_output_image_detail(&turn.model_info, detail), + detail: normalize_output_image_detail(&turn.model_info, detail) + .or(Some(DEFAULT_IMAGE_DETAIL)), } } diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index 128fda60dd..af53a59757 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -5,6 +5,7 @@ use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; @@ -253,7 +254,7 @@ fn summarize_tool_call_response_for_multimodal_function_output() { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,abcd".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -277,7 +278,7 @@ fn summarize_tool_call_response_for_multimodal_function_output() { } #[tokio::test] -async fn emitted_image_content_item_drops_unsupported_explicit_detail() { +async fn emitted_image_content_item_preserves_explicit_non_original_detail() { let (_session, turn) = make_session_and_context().await; let content_item = emitted_image_content_item( &turn, @@ -288,7 +289,7 @@ async fn emitted_image_content_item_drops_unsupported_explicit_detail() { content_item, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(ImageDetail::Low), } ); } @@ -314,7 +315,7 @@ async fn emitted_image_content_item_allows_explicit_original_detail_when_support } #[tokio::test] -async fn emitted_image_content_item_drops_explicit_original_detail_when_unsupported() { +async fn emitted_image_content_item_defaults_to_high_for_unsupported_original_detail() { let (_session, turn) = make_session_and_context().await; let content_item = emitted_image_content_item( @@ -327,7 +328,7 @@ async fn emitted_image_content_item_drops_explicit_original_detail_when_unsuppor content_item, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), } ); } @@ -356,7 +357,7 @@ fn summarize_tool_call_response_for_multimodal_custom_output() { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,abcd".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -1213,7 +1214,7 @@ console.log(out.type); image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] .as_slice() ); @@ -1268,7 +1269,7 @@ await codex.emitImage({ bytes: png, mimeType: "image/png" }); image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] .as_slice() ); @@ -1325,13 +1326,13 @@ await codex.emitImage( image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" .to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ] .as_slice() @@ -1387,7 +1388,7 @@ console.log("cell-complete"); image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] .as_slice() ); @@ -1465,11 +1466,11 @@ console.log("helpers-ran"); vec![ FunctionCallOutputContentItem::InputImage { image_url: data_url.to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputImage { image_url: data_url.to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ] ); @@ -1701,7 +1702,7 @@ await codex.emitImage("DATA:image/png;base64,AAA"); result.content_items.as_slice(), [FunctionCallOutputContentItem::InputImage { image_url: "DATA:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] .as_slice() ); @@ -1751,10 +1752,7 @@ await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" }); ) .await .expect_err("invalid detail should fail"); - assert!( - err.to_string() - .contains("only supports detail \"original\"") - ); + assert!(err.to_string().contains("expected detail to be one of")); assert!(session.get_pending_input().await.is_empty()); Ok(()) @@ -1804,7 +1802,7 @@ await codex.emitImage({ bytes: png, mimeType: "image/png", detail: null }); result.content_items.as_slice(), [FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] .as_slice() ); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index b5087de5a5..33bc8d378b 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -24,6 +24,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; use codex_protocol::error::CodexErr; use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; @@ -511,6 +512,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url: legacy_image_url.to_string(), + detail: Some(DEFAULT_IMAGE_DETAIL), }], end_turn: None, phase: None, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 474d221d27..383e6050e2 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1970,14 +1970,16 @@ image("data:image/png;base64,AAA"); items[1], serde_json::json!({ "type": "input_image", - "image_url": "https://example.com/image.jpg" + "image_url": "https://example.com/image.jpg", + "detail": "high" }), ); assert_eq!( items[2], serde_json::json!({ "type": "input_image", - "image_url": "data:image/png;base64,AAA" + "image_url": "data:image/png;base64,AAA", + "detail": "high" }), ); diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..a7ec8318f1 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -1,5 +1,6 @@ use anyhow::Context; use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -51,7 +52,7 @@ fn find_user_message_with_image(text: &str) -> Option { fn extract_image_url(item: &ResponseItem) -> Option { match item { ResponseItem::Message { content, .. } => content.iter().find_map(|span| match span { - ContentItem::InputImage { image_url } => Some(image_url.clone()), + ContentItem::InputImage { image_url, .. } => Some(image_url.clone()), _ => None, }), _ => None, @@ -150,7 +151,10 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu ContentItem::InputText { text: codex_protocol::models::local_image_open_tag_text(/*label_number*/ 1), }, - ContentItem::InputImage { image_url }, + ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ContentItem::InputText { text: codex_protocol::models::image_close_tag_text(), }, @@ -234,7 +238,10 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> ContentItem::InputText { text: codex_protocol::models::image_open_tag_text(), }, - ContentItem::InputImage { image_url }, + ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ContentItem::InputText { text: codex_protocol::models::image_close_tag_text(), }, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 733edf0632..a32f21f7cf 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -787,7 +787,8 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { output[1], json!({ "type": "input_image", - "image_url": OPENAI_PNG + "image_url": OPENAI_PNG, + "detail": "high" }) ); server.verify().await; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index 404d876dc3..2edd63d3d2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -21,6 +21,6 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 04:message/user: 05:message/user[4]: [01] - [02] + [02] [03] [04] USER_THREE diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 81c22e1b6c..d0c19f9be6 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -533,7 +533,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { ); assert_eq!( arr[1], - json!({"type": "input_image", "image_url": openai_png}) + json!({"type": "input_image", "image_url": openai_png, "detail": "high"}) ); Ok(()) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 891c4d2381..9f53e60d72 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -631,7 +631,10 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { .and_then(Value::as_array) .expect("function_call_output should be a content item array"); assert_eq!(output_items.len(), 1); - assert_eq!(output_items[0].get("detail"), None); + assert_eq!( + output_items[0].get("detail").and_then(Value::as_str), + Some("high") + ); let image_url = output_items[0] .get("image_url") .and_then(Value::as_str) @@ -728,7 +731,10 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a .and_then(Value::as_array) .expect("function_call_output should be a content item array"); assert_eq!(output_items.len(), 1); - assert_eq!(output_items[0].get("detail"), None); + assert_eq!( + output_items[0].get("detail").and_then(Value::as_str), + Some("high") + ); let image_url = output_items[0] .get("image_url") @@ -829,7 +835,10 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only .and_then(Value::as_array) .expect("function_call_output should be a content item array"); assert_eq!(output_items.len(), 1); - assert_eq!(output_items[0].get("detail"), None); + assert_eq!( + output_items[0].get("detail").and_then(Value::as_str), + Some("high") + ); let image_url = output_items[0] .get("image_url") .and_then(Value::as_str) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 15dc3d36e8..02cb8c432d 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -208,9 +208,18 @@ pub enum ResponseInputItem { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentItem { - InputText { text: String }, - InputImage { image_url: String }, - OutputText { text: String }, + InputText { + text: String, + }, + InputImage { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + detail: Option, + }, + OutputText { + text: String, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] @@ -222,6 +231,8 @@ pub enum ImageDetail { Original, } +pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] /// Classifies an assistant message as interim commentary or final answer text. @@ -935,6 +946,7 @@ pub fn local_image_content_items_with_label_number( } items.push(ContentItem::InputImage { image_url: image.into_data_url(), + detail: Some(DEFAULT_IMAGE_DETAIL), }); if label_number.is_some() { items.push(ContentItem::InputText { @@ -1082,7 +1094,10 @@ impl From> for ResponseInputItem { ContentItem::InputText { text: image_open_tag_text(), }, - ContentItem::InputImage { image_url }, + ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ContentItem::InputText { text: image_close_tag_text(), }, @@ -1225,7 +1240,7 @@ impl From crate::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { image_url } => { Self::InputImage { image_url, - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), } } } @@ -1462,9 +1477,13 @@ fn convert_mcp_content_to_items( .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(serde_json::Value::as_str) .and_then(|detail| match detail { + "auto" => Some(ImageDetail::Auto), + "low" => Some(ImageDetail::Low), + "high" => Some(ImageDetail::High), "original" => Some(ImageDetail::Original), _ => None, - }), + }) + .or(Some(DEFAULT_IMAGE_DETAIL)), } } Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText { @@ -1555,7 +1574,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,Zm9v".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] ); } @@ -1630,7 +1649,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,Zm9v".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] ); } @@ -1653,7 +1672,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), @@ -1672,7 +1691,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]; @@ -1695,7 +1714,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]); @@ -2267,7 +2286,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ] ); @@ -2294,7 +2313,7 @@ mod tests { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), }; @@ -2330,7 +2349,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }] ); @@ -2370,7 +2389,7 @@ mod tests { } #[test] - fn ignores_unknown_mcp_image_detail_metadata() -> Result<()> { + fn preserves_standard_detail_metadata_on_mcp_images() -> Result<()> { let call_tool_result = CallToolResult { content: vec![serde_json::json!({ "type": "image", @@ -2394,7 +2413,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), - detail: None, + detail: Some(ImageDetail::High), }] ); @@ -2574,7 +2593,10 @@ mod tests { ContentItem::InputText { text: image_open_tag_text(), }, - ContentItem::InputImage { image_url }, + ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ContentItem::InputText { text: image_close_tag_text(), }, @@ -2779,7 +2801,13 @@ mod tests { text: image_open_tag_text(), }) ); - assert_eq!(content.get(1), Some(&ContentItem::InputImage { image_url })); + assert_eq!( + content.get(1), + Some(&ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }) + ); assert_eq!( content.get(2), Some(&ContentItem::InputText { diff --git a/codex-rs/tools/src/image_detail.rs b/codex-rs/tools/src/image_detail.rs index 56987e4831..37086f691d 100644 --- a/codex-rs/tools/src/image_detail.rs +++ b/codex-rs/tools/src/image_detail.rs @@ -1,3 +1,4 @@ +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ModelInfo; @@ -14,7 +15,8 @@ pub fn normalize_output_image_detail( Some(ImageDetail::Original) if can_request_original_image_detail(model_info) => { Some(ImageDetail::Original) } - Some(ImageDetail::Original) | Some(_) | None => None, + Some(ImageDetail::Original) | None => None, + Some(ImageDetail::Auto | ImageDetail::Low | ImageDetail::High) => detail, } } @@ -30,7 +32,7 @@ pub fn sanitize_original_image_detail( if let FunctionCallOutputContentItem::InputImage { detail, .. } = item && matches!(detail, Some(ImageDetail::Original)) { - *detail = None; + *detail = Some(DEFAULT_IMAGE_DETAIL); } } } diff --git a/codex-rs/tools/src/image_detail_tests.rs b/codex-rs/tools/src/image_detail_tests.rs index c1a0f8ca1a..393a962ac4 100644 --- a/codex-rs/tools/src/image_detail_tests.rs +++ b/codex-rs/tools/src/image_detail_tests.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ModelInfo; @@ -66,17 +67,21 @@ fn explicit_original_is_dropped_without_model_support() { } #[test] -fn unsupported_non_original_detail_is_dropped() { +fn explicit_non_original_detail_is_preserved() { let model_info = model_info(); assert_eq!( normalize_output_image_detail(&model_info, Some(ImageDetail::Low)), - None + Some(ImageDetail::Low) + ); + assert_eq!( + normalize_output_image_detail(&model_info, Some(ImageDetail::High)), + Some(ImageDetail::High) ); } #[test] -fn sanitize_original_drops_original_without_support() { +fn sanitize_original_falls_back_to_high_without_support() { let mut items = vec![ FunctionCallOutputContentItem::InputText { text: "header".to_string(), @@ -101,7 +106,7 @@ fn sanitize_original_drops_original_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), diff --git a/codex-rs/utils/output-truncation/src/truncate_tests.rs b/codex-rs/utils/output-truncation/src/truncate_tests.rs index f159a6b62f..74acb15ca3 100644 --- a/codex-rs/utils/output-truncation/src/truncate_tests.rs +++ b/codex-rs/utils/output-truncation/src/truncate_tests.rs @@ -5,6 +5,7 @@ use crate::formatted_truncate_text; use crate::formatted_truncate_text_content_items_with_policy; use crate::truncate_function_output_items_with_policy; use crate::truncate_text; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputContentItem; use pretty_assertions::assert_eq; @@ -114,7 +115,7 @@ fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { FunctionCallOutputContentItem::InputText { text: t2.clone() }, FunctionCallOutputContentItem::InputImage { image_url: "img:mid".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: t3 }, FunctionCallOutputContentItem::InputText { text: t4 }, @@ -142,7 +143,7 @@ fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { output[2], FunctionCallOutputContentItem::InputImage { image_url: "img:mid".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), } ); @@ -214,7 +215,7 @@ fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_ima }, FunctionCallOutputContentItem::InputImage { image_url: "img:one".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "efgh".to_string(), @@ -224,7 +225,7 @@ fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_ima }, FunctionCallOutputContentItem::InputImage { image_url: "img:two".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ]; @@ -239,11 +240,11 @@ fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_ima }, FunctionCallOutputContentItem::InputImage { image_url: "img:one".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputImage { image_url: "img:two".to_string(), - detail: None, + detail: Some(DEFAULT_IMAGE_DETAIL), }, ] ); From e3f44ca3b30f85c139f717d271fa6f0e5aa64560 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 18 Apr 2026 19:04:53 -0700 Subject: [PATCH 17/18] Fix plugin cache panic when cwd is unavailable (#18499) ## Summary Fixes #16637. (I hit this bug after 11h of work on a long-running task.) Plugin cache initialization could panic when an already-absolute cache path was normalized through `AbsolutePathBuf::from_absolute_path`, because that path still consulted `current_dir()`. This changes absolute-path normalization so already-absolute paths do not depend on cwd, and makes plugin cache root construction available as a fallible path through `PluginStore::try_new()`. Plugin cache subpaths now use `AbsolutePathBuf::join()` instead of re-absolutizing derived absolute paths. --- codex-rs/core-plugins/src/loader.rs | 12 ++++-- codex-rs/core-plugins/src/store.rs | 30 ++++++------- codex-rs/core-plugins/src/store_tests.rs | 12 ++++++ .../utils/absolute-path/src/absolutize.rs | 4 ++ codex-rs/utils/absolute-path/src/lib.rs | 42 +++++++++++++++++++ 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 32b4b5d034..f3e5f8d4f1 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -129,7 +129,7 @@ pub fn refresh_curated_plugin_cache( plugin_version: &str, configured_curated_plugin_ids: &[PluginId], ) -> Result { - let store = PluginStore::new(codex_home.to_path_buf()); + let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; let curated_marketplace_path = AbsolutePathBuf::try_from( codex_home .join(".tmp/plugins") @@ -234,7 +234,7 @@ fn refresh_non_curated_plugin_cache_with_mode( .map(PluginId::as_key) .collect::>(); - let store = PluginStore::new(codex_home.to_path_buf()); + let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; let marketplace_outcome = list_marketplaces(additional_roots) .map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?; let mut plugin_sources = HashMap::::new(); @@ -742,7 +742,13 @@ pub async fn installed_plugin_telemetry_metadata( codex_home: &Path, plugin_id: &PluginId, ) -> PluginTelemetryMetadata { - let store = PluginStore::new(codex_home.to_path_buf()); + let store = match PluginStore::try_new(codex_home.to_path_buf()) { + Ok(store) => store, + Err(err) => { + warn!("failed to resolve plugin cache root: {err}"); + return PluginTelemetryMetadata::from_plugin_id(plugin_id); + } + }; let Some(plugin_root) = store.active_plugin_root(plugin_id) else { return PluginTelemetryMetadata::from_plugin_id(plugin_id); }; diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 4ada6a3d53..757aec8bc5 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -28,10 +28,15 @@ pub struct PluginStore { impl PluginStore { pub fn new(codex_home: PathBuf) -> Self { - Self { - root: AbsolutePathBuf::try_from(codex_home.join(PLUGINS_CACHE_DIR)) - .unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")), - } + Self::try_new(codex_home) + .unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")) + } + + pub fn try_new(codex_home: PathBuf) -> Result { + let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR)) + .map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?; + + Ok(Self { root }) } pub fn root(&self) -> &AbsolutePathBuf { @@ -39,22 +44,13 @@ impl PluginStore { } pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf { - AbsolutePathBuf::try_from( - self.root - .as_path() - .join(&plugin_id.marketplace_name) - .join(&plugin_id.plugin_name), - ) - .unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}")) + self.root + .join(&plugin_id.marketplace_name) + .join(&plugin_id.plugin_name) } pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf { - AbsolutePathBuf::try_from( - self.plugin_base_root(plugin_id) - .as_path() - .join(plugin_version), - ) - .unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}")) + self.plugin_base_root(plugin_id).join(plugin_version) } pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option { diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index c1d5dd1ab6..45feff61bd 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -33,6 +33,18 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { ); } +#[test] +fn try_new_rejects_relative_codex_home() { + let err = PluginStore::try_new(PathBuf::from("relative")) + .expect_err("relative codex home should fail"); + let err = err.to_string().replace('\\', "/"); + + assert_eq!( + err, + "failed to resolve plugin cache root: path is not absolute: relative/plugins/cache" + ); +} + #[test] fn install_copies_plugin_into_default_marketplace() { let tmp = tempdir().unwrap(); diff --git a/codex-rs/utils/absolute-path/src/absolutize.rs b/codex-rs/utils/absolute-path/src/absolutize.rs index 7965c81f7b..4c1842ada7 100644 --- a/codex-rs/utils/absolute-path/src/absolutize.rs +++ b/codex-rs/utils/absolute-path/src/absolutize.rs @@ -12,6 +12,10 @@ use std::path::Path; use std::path::PathBuf; pub(super) fn absolutize(path: &Path) -> std::io::Result { + if path.is_absolute() { + return Ok(normalize_path(path)); + } + Ok(absolutize_from(path, &std::env::current_dir()?)) } diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 200dfd3705..86161d23f0 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -328,6 +328,8 @@ mod tests { use crate::test_support::test_path_buf; use pretty_assertions::assert_eq; use std::fs; + #[cfg(unix)] + use std::process::Command; use tempfile::tempdir; #[test] @@ -341,6 +343,46 @@ mod tests { assert_eq!(abs_path_buf.as_path(), absolute_path.as_path()); } + #[cfg(unix)] + #[test] + fn from_absolute_path_does_not_read_current_dir_when_path_is_absolute() { + let status = Command::new(std::env::current_exe().expect("current test binary")) + .arg("from_absolute_path_with_removed_current_dir_child") + .arg("--ignored") + .env("CODEX_ABSOLUTE_PATH_REMOVED_CWD_CHILD", "1") + .status() + .expect("run child test"); + + assert!(status.success()); + } + + #[cfg(unix)] + #[test] + #[ignore] + fn from_absolute_path_with_removed_current_dir_child() { + if std::env::var_os("CODEX_ABSOLUTE_PATH_REMOVED_CWD_CHILD").is_none() { + return; + } + + let original_cwd = std::env::current_dir().expect("original cwd"); + let temp_dir = tempdir().expect("temp dir"); + let removed_cwd = temp_dir.path().to_path_buf(); + std::env::set_current_dir(&removed_cwd).expect("enter temp dir"); + std::fs::remove_dir(&removed_cwd).expect("remove current dir"); + std::env::current_dir().expect_err("current dir should be unavailable"); + + let path = AbsolutePathBuf::from_absolute_path(test_path_buf( + "/tmp/codex/../codex-home/plugins/cache", + )) + .expect("absolute path should not require current dir"); + + std::env::set_current_dir(original_cwd).expect("restore cwd"); + assert_eq!( + path.as_path(), + test_path_buf("/tmp/codex-home/plugins/cache") + ); + } + #[test] fn from_absolute_path_checked_rejects_relative_path() { let err = AbsolutePathBuf::from_absolute_path_checked("relative/path") From 996aa23e4ce900468047ed3ec57d1e7271f8d6de Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 18 Apr 2026 21:47:43 -0700 Subject: [PATCH 18/18] [5/6] Wire executor-backed MCP stdio (#18212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add the executor-backed RMCP stdio transport. - Wire MCP stdio placement through the executor environment config. - Cover local and executor-backed stdio paths with the existing MCP test helpers. ## Stack ```text o #18027 [6/6] Fail exec client operations after disconnect │ @ #18212 [5/6] Wire executor-backed MCP stdio │ o #18087 [4/6] Abstract MCP stdio server launching │ o #18020 [3/6] Add pushed exec process events │ o #18086 [2/6] Support piped stdin in exec process API │ o #18085 [1/6] Add MCP server environment config │ o main ``` --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 2 + .../app-server/src/codex_message_processor.rs | 37 +- codex-rs/cli/tests/mcp_list.rs | 2 +- codex-rs/codex-mcp/Cargo.toml | 1 + codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 26 +- .../codex-mcp/src/mcp_connection_manager.rs | 101 ++- codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/mcp_edit.rs | 21 +- codex-rs/config/src/mcp_types.rs | 68 +- codex-rs/config/src/mcp_types_tests.rs | 55 +- codex-rs/config/src/types.rs | 1 + codex-rs/core/config.schema.json | 24 +- codex-rs/core/src/config/config_tests.rs | 61 +- codex-rs/core/src/config/edit.rs | 21 +- codex-rs/core/src/config/edit_tests.rs | 2 +- codex-rs/core/src/connectors.rs | 3 + codex-rs/core/src/session/mcp.rs | 7 + codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 8 +- codex-rs/core/tests/suite/rmcp_client.rs | 589 +++++++++++++++++- codex-rs/rmcp-client/Cargo.toml | 1 + .../rmcp-client/src/bin/rmcp_test_server.rs | 4 +- .../rmcp-client/src/bin/test_stdio_server.rs | 47 +- .../src/executor_process_transport.rs | 376 +++++++++++ codex-rs/rmcp-client/src/lib.rs | 2 + codex-rs/rmcp-client/src/program_resolver.rs | 2 +- codex-rs/rmcp-client/src/rmcp_client.rs | 3 +- .../rmcp-client/src/stdio_server_launcher.rs | 272 +++++++- codex-rs/rmcp-client/src/utils.rs | 135 +++- codex-rs/utils/cli/src/format_env_display.rs | 17 +- 31 files changed, 1815 insertions(+), 76 deletions(-) create mode 100644 codex-rs/rmcp-client/src/executor_process_transport.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4091c0d243..e0c399a3b2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2414,6 +2414,7 @@ dependencies = [ "async-channel", "codex-async-utils", "codex-config", + "codex-exec-server", "codex-login", "codex-otel", "codex-plugin", @@ -2715,6 +2716,7 @@ dependencies = [ "axum", "codex-client", "codex-config", + "codex-exec-server", "codex-keyring-store", "codex-protocol", "codex-utils-cargo-bin", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 69cbd6cf4e..4cd97e99a6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -279,6 +279,7 @@ use codex_login::default_client::set_default_client_residency_requirement; use codex_login::login_with_api_key; use codex_login::request_device_code; use codex_login::run_login_server; +use codex_mcp::McpRuntimeEnvironment; use codex_mcp::McpServerStatusSnapshot; use codex_mcp::McpSnapshotDetail; use codex_mcp::collect_mcp_server_status_snapshot_with_detail; @@ -5724,10 +5725,40 @@ impl CodexMessageProcessor { .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; + let runtime_environment = match self.thread_manager.environment_manager().current().await { + Ok(Some(environment)) => { + // Status listing has no turn cwd. This fallback is used only + // by executor-backed stdio MCPs whose config omits `cwd`. + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) + } + Ok(None) => McpRuntimeEnvironment::new( + Arc::new(codex_exec_server::Environment::default()), + config.cwd.to_path_buf(), + ), + Err(err) => { + // TODO(aibrahim): Investigate degrading MCP status listing when + // executor environment creation fails. + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to create environment: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }; tokio::spawn(async move { - Self::list_mcp_server_status_task(outgoing, request, params, config, mcp_config, auth) - .await; + Self::list_mcp_server_status_task( + outgoing, + request, + params, + config, + mcp_config, + auth, + runtime_environment, + ) + .await; }); } @@ -5738,6 +5769,7 @@ impl CodexMessageProcessor { config: Config, mcp_config: codex_mcp::McpConfig, auth: Option, + runtime_environment: McpRuntimeEnvironment, ) { let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { McpServerStatusDetail::Full => McpSnapshotDetail::Full, @@ -5748,6 +5780,7 @@ impl CodexMessageProcessor { &mcp_config, auth.as_ref(), request_id.request_id.to_string(), + runtime_environment, detail, ) .await; diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index d41a3cc62a..bed3505985 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -55,7 +55,7 @@ async fn list_and_get_render_expected_output() -> Result<()> { .expect("docs server should exist after add"); match &mut docs_entry.transport { McpServerTransportConfig::Stdio { env_vars, .. } => { - *env_vars = vec!["APP_TOKEN".to_string(), "WORKSPACE_ID".to_string()]; + *env_vars = vec!["APP_TOKEN".into(), "WORKSPACE_ID".into()]; } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index adc38d4093..0aec1f3aaf 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } async-channel = { workspace = true } codex-async-utils = { workspace = true } codex-config = { workspace = true } +codex-exec-server = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index ed0d9b4122..766465f052 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -38,6 +38,7 @@ pub use mcp_connection_manager::CodexAppsToolsCacheKey; pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY; pub use mcp_connection_manager::McpConnectionManager; +pub use mcp_connection_manager::McpRuntimeEnvironment; pub use mcp_connection_manager::SandboxState; pub use mcp_connection_manager::ToolInfo; pub use mcp_connection_manager::codex_apps_tools_cache_key; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 1dc9db0789..448259d5b7 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -35,6 +35,7 @@ use codex_protocol::protocol::SandboxPolicy; use serde_json::Value; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mcp_connection_manager::McpRuntimeEnvironment; use crate::mcp_connection_manager::codex_apps_tools_cache_key; pub type McpManager = McpConnectionManager; @@ -321,14 +322,23 @@ pub async fn collect_mcp_snapshot( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, + runtime_environment: McpRuntimeEnvironment, ) -> McpListToolsResponseEvent { - collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await + collect_mcp_snapshot_with_detail( + config, + auth, + submit_id, + runtime_environment, + McpSnapshotDetail::Full, + ) + .await } pub async fn collect_mcp_snapshot_with_detail( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, + runtime_environment: McpRuntimeEnvironment, detail: McpSnapshotDetail, ) -> McpListToolsResponseEvent { let mcp_servers = effective_mcp_servers(config, auth); @@ -356,6 +366,7 @@ pub async fn collect_mcp_snapshot_with_detail( submit_id, tx_event, SandboxPolicy::new_read_only_policy(), + runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), tool_plugin_provenance, @@ -386,15 +397,23 @@ pub async fn collect_mcp_server_status_snapshot( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, + runtime_environment: McpRuntimeEnvironment, ) -> McpServerStatusSnapshot { - collect_mcp_server_status_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full) - .await + collect_mcp_server_status_snapshot_with_detail( + config, + auth, + submit_id, + runtime_environment, + McpSnapshotDetail::Full, + ) + .await } pub async fn collect_mcp_server_status_snapshot_with_detail( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, + runtime_environment: McpRuntimeEnvironment, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); @@ -422,6 +441,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( submit_id, tx_event, SandboxPolicy::new_read_only_policy(), + runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), tool_plugin_provenance, diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs index 7a290973f1..10c48e040e 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -36,6 +36,7 @@ use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_config::Constrained; use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::Environment; use codex_protocol::ToolName; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; @@ -50,6 +51,7 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::SandboxPolicy; use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::SendElicitation; @@ -493,6 +495,7 @@ impl AsyncManagedClient { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, tool_plugin_provenance: Arc, + runtime_environment: McpRuntimeEnvironment, ) -> Self { let tool_filter = ToolFilter::from_config(&config); let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( @@ -509,8 +512,15 @@ impl AsyncManagedClient { return Err(error.into()); } - let client = - Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?); + let client = Arc::new( + make_rmcp_client( + &server_name, + config.clone(), + store_mode, + runtime_environment, + ) + .await?, + ); match start_server_task( server_name, client, @@ -650,6 +660,37 @@ pub struct McpConnectionManager { elicitation_requests: ElicitationRequestManager, } +/// Runtime placement information used when starting MCP server transports. +/// +/// `McpConfig` describes what servers exist. This value describes where those +/// servers should run for the current caller. Keep it explicit at manager +/// construction time so status/snapshot paths and real sessions make the same +/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is +/// used only when an executor-backed stdio server omits `cwd` and the executor +/// API still needs a concrete process working directory. +#[derive(Clone)] +pub struct McpRuntimeEnvironment { + environment: Arc, + fallback_cwd: PathBuf, +} + +impl McpRuntimeEnvironment { + pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { + Self { + environment, + fallback_cwd, + } + } + + fn environment(&self) -> Arc { + Arc::clone(&self.environment) + } + + fn fallback_cwd(&self) -> PathBuf { + self.fallback_cwd.clone() + } +} + impl McpConnectionManager { pub fn configured_servers(&self, config: &McpConfig) -> HashMap { configured_mcp_servers(config) @@ -710,6 +751,7 @@ impl McpConnectionManager { submit_id: String, tx_event: Sender, initial_sandbox_policy: SandboxPolicy, + runtime_environment: McpRuntimeEnvironment, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, tool_plugin_provenance: ToolPluginProvenance, @@ -754,6 +796,7 @@ impl McpConnectionManager { elicitation_requests.clone(), codex_apps_tools_cache_context, Arc::clone(&tool_plugin_provenance), + runtime_environment.clone(), ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -1484,9 +1527,25 @@ struct StartServerTaskParams { async fn make_rmcp_client( server_name: &str, - transport: McpServerTransportConfig, + config: McpServerConfig, store_mode: OAuthCredentialsStoreMode, + runtime_environment: McpRuntimeEnvironment, ) -> Result { + let McpServerConfig { + transport, + experimental_environment, + .. + } = config; + let remote_environment = match experimental_environment.as_deref() { + None | Some("local") => false, + Some("remote") => true, + Some(environment) => { + return Err(StartupOutcomeError::from(anyhow!( + "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" + ))); + } + }; + match transport { McpServerTransportConfig::Stdio { command, @@ -1502,7 +1561,24 @@ async fn make_rmcp_client( .map(|(key, value)| (key.into(), value.into())) .collect::>() }); - let launcher = Arc::new(LocalStdioServerLauncher) as Arc; + let launcher = if remote_environment { + let exec_environment = runtime_environment.environment(); + if !exec_environment.is_remote() { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server `{server_name}` requires a remote executor environment" + ))); + } + Arc::new(ExecutorStdioServerLauncher::new( + exec_environment.get_exec_backend(), + runtime_environment.fallback_cwd(), + )) + } else { + Arc::new(LocalStdioServerLauncher) as Arc + }; + + // `RmcpClient` always sees a launched MCP stdio server. The + // launcher hides whether that means a local child process or an + // executor process whose stdin/stdout bytes cross the process API. RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) .await .map_err(|err| StartupOutcomeError::from(anyhow!(err))) @@ -1513,6 +1589,23 @@ async fn make_rmcp_client( env_http_headers, bearer_token_env_var, } => { + if remote_environment { + if !runtime_environment.environment().is_remote() { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server `{server_name}` requires a remote executor environment" + ))); + } + return Err(StartupOutcomeError::from(anyhow!( + // Remote HTTP needs the future low-level executor + // `network/request` API so reqwest runs on the executor side. + // Do not fall back to local HTTP here; the config explicitly + // asked for remote placement. + "remote streamable HTTP MCP server `{server_name}` is not implemented yet" + ))); + } + + // Local streamable HTTP remains the existing reqwest path from + // the orchestrator process. let resolved_bearer_token = match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) { Ok(token) => token, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index c1d9041698..a26d5afe0a 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -72,6 +72,7 @@ pub use mcp_edit::load_global_mcp_servers; pub use mcp_types::AppToolApproval; pub use mcp_types::McpServerConfig; pub use mcp_types::McpServerDisabledReason; +pub use mcp_types::McpServerEnvVar; pub use mcp_types::McpServerToolConfig; pub use mcp_types::McpServerTransportConfig; pub use mcp_types::RawMcpServerConfig; diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index c4bb38c543..f5881a1257 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -14,6 +14,7 @@ use toml_edit::value; use crate::AppToolApproval; use crate::CONFIG_TOML_FILE; use crate::McpServerConfig; +use crate::McpServerEnvVar; use crate::McpServerTransportConfig; pub async fn load_global_mcp_servers( @@ -142,7 +143,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { entry["env"] = table_from_pairs(env.iter()); } if !env_vars.is_empty() { - entry["env_vars"] = array_from_strings(env_vars); + entry["env_vars"] = array_from_env_vars(env_vars); } if let Some(cwd) = cwd { entry["cwd"] = value(cwd.to_string_lossy().to_string()); @@ -247,6 +248,24 @@ fn array_from_strings(values: &[String]) -> TomlItem { TomlItem::Value(array.into()) } +fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { + let mut array = toml_edit::Array::new(); + for env_var in env_vars { + match env_var { + McpServerEnvVar::Name(name) => array.push(name.clone()), + McpServerEnvVar::Config { name, source } => { + let mut table = toml_edit::InlineTable::new(); + table.insert("name", name.clone().into()); + if let Some(source) = source { + table.insert("source", source.clone().into()); + } + array.push(table); + } + } + } + TomlItem::Value(array.into()) +} + fn table_from_pairs<'a, I>(pairs: I) -> TomlItem where I: IntoIterator, diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 75b68c3f94..a276cd7070 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -56,6 +56,64 @@ pub struct McpServerToolConfig { pub approval_mode: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +pub enum McpServerEnvVar { + Name(String), + Config { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + }, +} + +impl McpServerEnvVar { + pub fn name(&self) -> &str { + match self { + McpServerEnvVar::Name(name) => name, + McpServerEnvVar::Config { name, .. } => name, + } + } + + pub fn source(&self) -> Option<&str> { + match self { + McpServerEnvVar::Name(_) => None, + McpServerEnvVar::Config { source, .. } => source.as_deref(), + } + } + + pub fn is_remote_source(&self) -> bool { + self.source() == Some("remote") + } + + pub fn validate_source(&self) -> Result<(), String> { + match self.source() { + None | Some("local") | Some("remote") => Ok(()), + Some(source) => Err(format!( + "unsupported env_vars source `{source}`; expected `local` or `remote`" + )), + } + } +} + +impl From for McpServerEnvVar { + fn from(value: String) -> Self { + Self::Name(value) + } +} + +impl From<&str> for McpServerEnvVar { + fn from(value: &str) -> Self { + Self::Name(value.to_string()) + } +} + +impl AsRef for McpServerEnvVar { + fn as_ref(&self) -> &str { + self.name() + } +} + #[derive(Serialize, Debug, Clone, PartialEq)] pub struct McpServerConfig { #[serde(flatten)] @@ -133,7 +191,7 @@ pub struct RawMcpServerConfig { #[serde(default)] pub env: Option>, #[serde(default)] - pub env_vars: Option>, + pub env_vars: Option>, #[serde(default)] pub cwd: Option, pub http_headers: Option>, @@ -235,11 +293,15 @@ impl TryFrom for McpServerConfig { throw_if_set("stdio", "http_headers", http_headers.as_ref())?; throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?; + let env_vars = env_vars.unwrap_or_default(); + for env_var in &env_vars { + env_var.validate_source()?; + } McpServerTransportConfig::Stdio { command, args: args.unwrap_or_default(), env, - env_vars: env_vars.unwrap_or_default(), + env_vars, cwd, } } else if let Some(url) = url { @@ -303,7 +365,7 @@ pub enum McpServerTransportConfig { #[serde(default, skip_serializing_if = "Option::is_none")] env: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] - env_vars: Vec, + env_vars: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] cwd: Option, }, diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index dff4a6bbf5..50f705d0f8 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -91,12 +91,65 @@ fn deserialize_stdio_command_server_config_with_env_vars() { command: "echo".to_string(), args: vec![], env: None, - env_vars: vec!["FOO".to_string(), "BAR".to_string()], + env_vars: vec!["FOO".into(), "BAR".into()], cwd: None, } ); } +#[test] +fn deserialize_stdio_command_server_config_with_env_var_sources() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = [ + "LEGACY_TOKEN", + { name = "LOCAL_TOKEN", source = "local" }, + { name = "REMOTE_TOKEN", source = "remote" }, + ] + "#, + ) + .expect("should deserialize command config with sourced env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec![ + McpServerEnvVar::Name("LEGACY_TOKEN".to_string()), + McpServerEnvVar::Config { + name: "LOCAL_TOKEN".to_string(), + source: Some("local".to_string()), + }, + McpServerEnvVar::Config { + name: "REMOTE_TOKEN".to_string(), + source: Some("remote".to_string()), + }, + ], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_rejects_unknown_env_var_source() { + let err = toml::from_str::( + r#" + command = "echo" + env_vars = [{ name = "TOKEN", source = "elsewhere" }] + "#, + ) + .expect_err("unsupported env var source should be rejected"); + + assert!( + err.to_string() + .contains("unsupported env_vars source `elsewhere`"), + "unexpected error: {err}" + ); +} + #[test] fn deserialize_stdio_command_server_config_with_cwd() { let cfg: McpServerConfig = toml::from_str( diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index ae62216d25..50a24db534 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -6,6 +6,7 @@ pub use crate::mcp_types::AppToolApproval; pub use crate::mcp_types::McpServerConfig; pub use crate::mcp_types::McpServerDisabledReason; +pub use crate::mcp_types::McpServerEnvVar; pub use crate::mcp_types::McpServerToolConfig; pub use crate::mcp_types::McpServerTransportConfig; pub use crate::mcp_types::RawMcpServerConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5135e4b0db..240d84fdb4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -876,6 +876,28 @@ ], "type": "string" }, + "McpServerEnvVar": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, "McpServerToolConfig": { "additionalProperties": false, "description": "Per-tool approval settings for a single MCP server tool.", @@ -1570,7 +1592,7 @@ "env_vars": { "default": null, "items": { - "type": "string" + "$ref": "#/definitions/McpServerEnvVar" }, "type": "array" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index bc65aac61c..edf5b9ebc2 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -32,6 +32,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::BundledSkillsConfig; use codex_config::types::FeedbackConfigToml; use codex_config::types::HistoryPersistence; +use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; @@ -2490,7 +2491,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { command: "docs-server".to_string(), args: Vec::new(), env: None, - env_vars: vec!["ALPHA".to_string(), "BETA".to_string()], + env_vars: vec!["ALPHA".into(), "BETA".into()], cwd: None, }, experimental_environment: None, @@ -2526,7 +2527,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { env_vars, .. } => { - assert_eq!(env_vars, &vec!["ALPHA".to_string(), "BETA".to_string()]); + assert_eq!(env_vars, &vec!["ALPHA".into(), "BETA".into()]); } other => panic!("unexpected transport {other:?}"), } @@ -2534,6 +2535,62 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: vec![ + "LEGACY".into(), + McpServerEnvVar::Config { + name: "REMOTE_TOKEN".to_string(), + source: Some("remote".to_string()), + }, + ], + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]); + + apply_blocking( + codex_home.path(), + /*profile*/ None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized + .contains(r#"env_vars = ["LEGACY", { name = "REMOTE_TOKEN", source = "remote" }]"#), + "serialized config missing sourced env_vars field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + assert_eq!(loaded, servers); + + Ok(()) +} + #[tokio::test] async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 8f336f7ff5..e7cf3651c1 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -136,6 +136,7 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> mod document_helpers { use codex_config::types::AppToolApproval; use codex_config::types::McpServerConfig; + use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use toml_edit::Array as TomlArray; @@ -198,7 +199,7 @@ mod document_helpers { entry["env"] = table_from_pairs(env.iter()); } if !env_vars.is_empty() { - entry["env_vars"] = array_from_iter(env_vars.iter().cloned()); + entry["env_vars"] = array_from_env_vars(env_vars); } if let Some(cwd) = cwd { entry["cwd"] = value(cwd.to_string_lossy().to_string()); @@ -348,6 +349,24 @@ mod document_helpers { TomlItem::Value(array.into()) } + fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { + let mut array = TomlArray::new(); + for env_var in env_vars { + match env_var { + McpServerEnvVar::Name(name) => array.push(name.clone()), + McpServerEnvVar::Config { name, source } => { + let mut table = InlineTable::new(); + table.insert("name", name.clone().into()); + if let Some(source) = source { + table.insert("source", source.clone().into()); + } + array.push(table); + } + } + } + TomlItem::Value(array.into()) + } + fn table_from_pairs<'a, I>(pairs: I) -> TomlItem where I: IntoIterator, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 84a4a34ea6..2a966d9b41 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -696,7 +696,7 @@ fn blocking_replace_mcp_servers_round_trips() { .into_iter() .collect(), ), - env_vars: vec!["FOO".to_string()], + env_vars: vec!["FOO".into()], cwd: None, }, experimental_environment: None, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 480c163147..933ce0ac76 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -13,6 +13,7 @@ pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_connectors::AllConnectorsCacheKey; use codex_connectors::DirectoryListResponse; +use codex_exec_server::Environment; use codex_login::token_data::TokenData; use codex_protocol::protocol::SandboxPolicy; use codex_tools::DiscoverableTool; @@ -37,6 +38,7 @@ use codex_login::default_client::create_client; use codex_login::default_client::originator; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::McpConnectionManager; +use codex_mcp::McpRuntimeEnvironment; use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; @@ -241,6 +243,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( INITIAL_SUBMIT_ID.to_owned(), tx_event, SandboxPolicy::new_read_only_policy(), + McpRuntimeEnvironment::new(Arc::new(Environment::default()), config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), ToolPluginProvenance::default(), diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 56628f0543..8b7935a50c 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -205,6 +205,13 @@ impl Session { turn_context.sub_id.clone(), self.get_tx_event(), turn_context.sandbox_policy.get().clone(), + McpRuntimeEnvironment::new( + turn_context + .environment + .clone() + .unwrap_or_else(|| Arc::new(Environment::default())), + turn_context.cwd.to_path_buf(), + ), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), tool_plugin_provenance, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 594a2d1034..5cda5debe6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -55,6 +55,7 @@ use codex_login::CodexAuth; use codex_login::auth_env_telemetry::collect_auth_env_telemetry; use codex_login::default_client::originator; use codex_mcp::McpConnectionManager; +use codex_mcp::McpRuntimeEnvironment; use codex_mcp::ToolInfo; use codex_mcp::codex_apps_tools_cache_key; #[cfg(test)] diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 766ac79ec1..ac5be4421c 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -676,7 +676,7 @@ impl Session { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment, + environment: environment.clone(), }; services .model_client @@ -770,6 +770,12 @@ impl Session { INITIAL_SUBMIT_ID.to_owned(), tx_event.clone(), session_configuration.sandbox_policy.get().clone(), + McpRuntimeEnvironment::new( + environment + .clone() + .unwrap_or_else(|| Arc::new(Environment::default())), + session_configuration.cwd.to_path_buf(), + ), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth), tool_plugin_provenance, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index a32f21f7cf..7875f09810 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1,17 +1,26 @@ +#![allow(clippy::expect_used)] + +use anyhow::Context as _; +use anyhow::ensure; use std::collections::HashMap; use std::ffi::OsStr; use std::ffi::OsString; use std::fs; use std::net::TcpListener; use std::path::Path; +use std::path::PathBuf; +use std::process::Command as StdCommand; use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; use codex_config::types::McpServerConfig; +use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; +use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; @@ -34,6 +43,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use codex_utils_cargo_bin::cargo_bin; use core_test_support::assert_regex_match; +use core_test_support::remote_env_env_var; use core_test_support::responses; use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::mount_models_once; @@ -54,6 +64,7 @@ use tokio::process::Child; use tokio::process::Command; use tokio::time::Instant; use tokio::time::sleep; +use wiremock::MockServer; static OPENAI_PNG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD0AAAA9CAYAAAAeYmHpAAAE6klEQVR4Aeyau44UVxCGx1fZsmRLlm3Zoe0XcGQ5cUiCCIgJeS9CHgAhMkISQnIuGQgJEkBcxLW+nqnZ6uqqc+nuWRC7q/P3qetf9e+MtOwyX25O4Nep6JPyop++0qev9HrfgZ+F6r2DuB/vHOrt/UIkqdDHYvujOW6fO7h/CNEI+a5jc+pBR8uy0jVFsziYu5HtfSUk+Io34q921hLNctFSX0gwww+S8wce8K1LfCU+cYW4888aov8NxqvQILUPPReLOrm6zyLxa4i+6VZuFbJo8d1MOHZm+7VUtB/aIvhPWc/3SWg49JcwFLlHxuXKjtyloo+YNhuW3VS+WPBuUEMvCFKjEDVgFBQHXrnazpqiSxNZCkQ1kYiozsbm9Oz7l4i2Il7vGccGNWAc3XosDrZe/9P3ZnMmzHNEQw4smf8RQ87XEAMsC7Az0Au+dgXerfH4+sHvEc0SYGic8WBBUGqFH2gN7yDrazy7m2pbRTeRmU3+MjZmr1h6LJgPbGy23SI6GlYT0brQ71IY8Us4PNQCm+zepSbaD2BY9xCaAsD9IIj/IzFmKMSdHHonwdZATbTnYREf6/VZGER98N9yCWIvXQwXDoDdhZJoT8jwLnJXDB9w4Sb3e6nK5ndzlkTLnP3JBu4LKkbrYrU69gCVceV0JvpyuW1xlsUVngzhwMetn/XamtTORF9IO5YnWNiyeF9zCAfqR3fUW+vZZKLtgP+ts8BmQRBREAdRDhH3o8QuRh/YucNFz2BEjxbRN6LGzphfKmvP6v6QhqIQyZ8XNJ0W0X83MR1PEcJBNO2KC2Z1TW/v244scp9FwRViZxIOBF0Lctk7ZVSavdLvRlV1hz/ysUi9sr8CIcB3nvWBwA93ykTz18eAYxQ6N/K2DkPA1lv3iXCwmDUT7YkjIby9siXueIJj9H+pzSqJ9oIuJWTUgSSt4WO7o/9GGg0viR4VinNRUDoIj34xoCd6pxD3aK3zfdbnx5v1J3ZNNEJsE0sBG7N27ReDrJc4sFxz7dI/ZAbOmmiKvHBitQXpAdR6+F7v+/ol/tOouUV01EeMZQF2BoQDn6dP4XNr+j9GZEtEK1/L8pFw7bd3a53tsTa7WD+054jOFmPg1XBKPQgnqFfmFcy32ZRvjmiIIQTYFvyDxQ8nH8WIwwGwlyDjDznnilYyFr6njrlZwsKkBpO59A7OwgdzPEWRm+G+oeb7IfyNuzjEEVLrOVxJsxvxwF8kmCM6I2QYmJunz4u4TrADpfl7mlbRTWQ7VmrBzh3+C9f6Grc3YoGN9dg/SXFthpRsT6vobfXRs2VBlgBHXVMLHjDNbIZv1sZ9+X3hB09cXdH1JKViyG0+W9bWZDa/r2f9zAFR71sTzGpMSWz2iI4YssWjWo3REy1MDGjdwe5e0dFSiAC1JakBvu4/CUS8Eh6dqHdU0Or0ioY3W5ClSqDXAy7/6SRfgw8vt4I+tbvvNtFT2kVDhY5+IGb1rCqYaXNF08vSALsXCPmt0kQNqJT1p5eI1mkIV/BxCY1z85lOzeFbPBQHURkkPTlwTYK9gTVE25l84IbFFN+YJDHjdpn0gq6mrHht0dkcjbM4UL9283O5p77GN+SPW/QwVB4IUYg7Or+Kp7naR6qktP98LNF2UxWo9yObPIT9KYg+hK4i56no4rfnM0qeyFf6AwAAAP//trwR3wAAAAZJREFUAwBZ0sR75itw5gAAAABJRU5ErkJggg=="; @@ -86,6 +97,73 @@ enum McpCallEvent { End(String), } +const REMOTE_MCP_ENVIRONMENT: &str = "remote"; + +fn remote_aware_experimental_environment() -> Option { + // These tests run locally in normal CI and against the Docker-backed + // executor in full-ci. Match that shared test environment instead of + // parameterizing each stdio MCP test with its own local/remote cases. + std::env::var_os(remote_env_env_var()).map(|_| REMOTE_MCP_ENVIRONMENT.to_string()) +} + +/// Returns the stdio MCP test server command path for the active test placement. +/// +/// Local test runs can execute the host-built test binary directly. Remote-aware +/// runs start MCP stdio through the executor inside Docker, so the host path +/// would be meaningless to the process that actually launches the server. When +/// the remote test environment is active, copy the binary into the executor +/// container and return that in-container path instead. +fn remote_aware_stdio_server_bin() -> anyhow::Result { + let bin = stdio_server_bin()?; + let Some(container_name) = std::env::var_os(remote_env_env_var()) else { + return Ok(bin); + }; + let container_name = container_name + .into_string() + .map_err(|value| anyhow::anyhow!("remote env container name must be utf-8: {value:?}"))?; + + // Keep the Docker path rewrite scoped to tests that use `build_remote_aware`. + // Other MCP tests still start their stdio server from the orchestrator test + // process, even when the full-ci remote env is present. + // + // Remote-aware MCP tests run the executor inside Docker. The stdio test + // server is built on the host, so hand the executor a copied in-container + // path instead of the host build artifact path. + // Several remote-aware MCP tests can run in parallel; give each copied + // binary its own path so one test cannot replace another test's executable. + let unique_suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let remote_path = format!( + "/tmp/codex-remote-env/test_stdio_server-{}-{unique_suffix}", + std::process::id() + ); + let container_target = format!("{container_name}:{remote_path}"); + let copy_output = StdCommand::new("docker") + .arg("cp") + .arg(&bin) + .arg(&container_target) + .output() + .with_context(|| format!("copy {bin} to remote MCP test env"))?; + ensure!( + copy_output.status.success(), + "docker cp test_stdio_server failed: stdout={} stderr={}", + String::from_utf8_lossy(©_output.stdout).trim(), + String::from_utf8_lossy(©_output.stderr).trim() + ); + + let chmod_output = StdCommand::new("docker") + .args(["exec", &container_name, "chmod", "+x", remote_path.as_str()]) + .output() + .context("mark remote test_stdio_server executable")?; + ensure!( + chmod_output.status.success(), + "docker chmod test_stdio_server failed: stdout={} stderr={}", + String::from_utf8_lossy(&chmod_output.stdout).trim(), + String::from_utf8_lossy(&chmod_output.stderr).trim() + ); + + Ok(remote_path) +} + async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Result<()> { let tools_ready_deadline = Instant::now() + Duration::from_secs(30); loop { @@ -115,6 +193,7 @@ async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Resu #[derive(Default)] struct TestMcpServerOptions { + experimental_environment: Option, supports_parallel_tool_calls: bool, tool_timeout_sec: Option, } @@ -122,14 +201,23 @@ struct TestMcpServerOptions { fn stdio_transport( command: String, env: Option>, - env_vars: Vec, + env_vars: Vec, +) -> McpServerTransportConfig { + stdio_transport_with_cwd(command, env, env_vars, /*cwd*/ None) +} + +fn stdio_transport_with_cwd( + command: String, + env: Option>, + env_vars: Vec, + cwd: Option, ) -> McpServerTransportConfig { McpServerTransportConfig::Stdio { command, args: Vec::new(), env, env_vars, - cwd: None, + cwd, } } @@ -144,7 +232,7 @@ fn insert_mcp_server( server_name.to_string(), McpServerConfig { transport, - experimental_environment: None, + experimental_environment: options.experimental_environment, enabled: true, required: false, supports_parallel_tool_calls: options.supports_parallel_tool_calls, @@ -164,6 +252,105 @@ fn insert_mcp_server( } } +async fn call_cwd_tool( + server: &MockServer, + fixture: &TestCodex, + server_name: &str, + call_id: &str, +) -> anyhow::Result { + let namespace = format!("mcp__{server_name}__"); + mount_sse_once( + server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace(call_id, &namespace, "cwd", r#"{}"#), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once( + server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp cwd tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let session_model = fixture.session_configured.model.clone(); + fixture + .codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "call the rmcp cwd tool".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: fixture.config.cwd.to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) + .await; + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + let structured_content = end + .result + .as_ref() + .expect("rmcp cwd tool should return success") + .structured_content + .as_ref() + .expect("structured content") + .clone(); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + Ok(structured_content) +} + +fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) { + let actual_cwd = structured + .get("cwd") + .and_then(Value::as_str) + .expect("cwd tool should return a string cwd"); + + if std::env::var_os(remote_env_env_var()).is_some() { + assert_eq!( + structured, + &json!({ + "cwd": expected_cwd.to_string_lossy(), + }) + ); + return; + } + + // Local Windows can report the same absolute directory through an 8.3 path. + // Canonical paths keep the assertion focused on cwd precedence. + assert_eq!( + Path::new(actual_cwd) + .canonicalize() + .expect("cwd tool path should exist"), + expected_cwd + .canonicalize() + .expect("expected cwd should exist"), + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[serial(mcp_test_value)] async fn stdio_server_round_trip() -> anyhow::Result<()> { @@ -199,7 +386,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { .await; let expected_env_value = "propagated-env"; - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -214,10 +401,13 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { )])), Vec::new(), ), - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); @@ -314,6 +504,118 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_cwd)] +async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let server_name = "rmcp_configured_cwd"; + let expected_cwd = Arc::new(Mutex::new(None::)); + let expected_cwd_for_config = Arc::clone(&expected_cwd); + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + + let fixture = test_codex() + .with_workspace_setup(|cwd, fs| async move { + fs.create_directory( + &cwd.join("mcp-configured-cwd"), + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }) + .with_config(move |config| { + let configured_cwd = config.cwd.join("mcp-configured-cwd").into_path_buf(); + *expected_cwd_for_config + .lock() + .expect("expected cwd lock should not be poisoned") = Some(configured_cwd.clone()); + insert_mcp_server( + config, + server_name, + stdio_transport_with_cwd( + rmcp_test_server_bin, + Some(HashMap::from([( + "MCP_TEST_VALUE".to_string(), + "configured-cwd".to_string(), + )])), + Vec::new(), + Some(configured_cwd), + ), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, + ); + }) + .build_remote_aware(&server) + .await?; + + let expected_cwd = expected_cwd + .lock() + .expect("expected cwd lock should not be poisoned") + .clone() + .expect("test config should record configured MCP cwd"); + let structured = call_cwd_tool(&server, &fixture, server_name, "call-configured-cwd").await?; + + assert_cwd_tool_output(&structured, &expected_cwd); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_cwd)] +async fn remote_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> anyhow::Result<()> +{ + skip_if_no_network!(Ok(())); + if std::env::var_os(remote_env_env_var()).is_none() { + return Ok(()); + } + + let server = responses::start_mock_server().await; + let server_name = "rmcp_fallback_cwd"; + let expected_cwd = Arc::new(Mutex::new(None::)); + let expected_cwd_for_config = Arc::clone(&expected_cwd); + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + + let fixture = test_codex() + .with_config(move |config| { + *expected_cwd_for_config + .lock() + .expect("expected cwd lock should not be poisoned") = + Some(config.cwd.to_path_buf()); + insert_mcp_server( + config, + server_name, + stdio_transport( + rmcp_test_server_bin, + Some(HashMap::from([( + "MCP_TEST_VALUE".to_string(), + "fallback-cwd".to_string(), + )])), + Vec::new(), + ), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, + ); + }) + .build_remote_aware(&server) + .await?; + + let expected_cwd = expected_cwd + .lock() + .expect("expected cwd lock should not be poisoned") + .clone() + .expect("test config should record runtime fallback cwd"); + let structured = call_cwd_tool(&server, &fixture, server_name, "call-fallback-cwd").await?; + + assert_cwd_tool_output(&structured, &expected_cwd); + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -343,17 +645,20 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { insert_mcp_server( config, server_name, stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let tools_ready_deadline = Instant::now() + Duration::from_secs(30); @@ -415,7 +720,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> ); assert_eq!( sandbox_meta.get("sandboxCwd").and_then(Value::as_str), - fixture.cwd.path().to_str() + fixture.config.cwd.as_path().to_str() ); assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); @@ -455,7 +760,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -464,12 +769,13 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: server_name, stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), tool_timeout_sec: Some(Duration::from_secs(2)), ..Default::default() }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); @@ -586,7 +892,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -595,12 +901,13 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res server_name, stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), supports_parallel_tool_calls: true, tool_timeout_sec: Some(Duration::from_secs(2)), }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); @@ -676,7 +983,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { .await; // Build the stdio rmcp server and pass the image as data URL so it can construct ImageContent. - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -691,10 +998,13 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { )])), Vec::new(), ), - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); @@ -830,7 +1140,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_model("gpt-5.3-codex") @@ -839,10 +1149,13 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re config, server_name, stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); @@ -1053,7 +1366,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -1069,10 +1382,13 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re )])), Vec::new(), ), - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; fixture @@ -1168,7 +1484,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { let expected_env_value = "propagated-env-from-whitelist"; let _guard = EnvVarGuard::set("MCP_TEST_VALUE", OsStr::new(expected_env_value)); - let rmcp_test_server_bin = stdio_server_bin()?; + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { @@ -1178,12 +1494,15 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { stdio_transport( rmcp_test_server_bin, /*env*/ None, - vec!["MCP_TEST_VALUE".to_string()], + vec!["MCP_TEST_VALUE".into()], ), - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); @@ -1262,6 +1581,222 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_env_source)] +async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let call_id = "call-local-source"; + let server_name = "rmcp_local_source"; + let namespace = format!("mcp__{server_name}__"); + let env_name = "MCP_TEST_LOCAL_SOURCE"; + let expected_env_value = "propagated-explicit-local-source"; + + mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + &namespace, + "echo", + &format!(r#"{{"message":"ping","env_var":"{env_name}"}}"#), + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let _guard = EnvVarGuard::set(env_name, OsStr::new(expected_env_value)); + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport( + rmcp_test_server_bin, + /*env*/ None, + vec![McpServerEnvVar::Config { + name: env_name.to_string(), + source: Some("local".to_string()), + }], + ), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, + ); + }) + .build_remote_aware(&server) + .await?; + + let session_model = fixture.session_configured.model.clone(); + fixture + .codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "call the rmcp echo tool".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) + .await; + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + let structured = end + .result + .as_ref() + .expect("rmcp echo tool should return success") + .structured_content + .as_ref() + .expect("structured content"); + assert_eq!(structured["env"], expected_env_value); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_env_source)] +async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + if std::env::var_os(remote_env_env_var()).is_none() { + return Ok(()); + } + + let server = responses::start_mock_server().await; + let call_id = "call-remote-source"; + let server_name = "rmcp_remote_source"; + let namespace = format!("mcp__{server_name}__"); + let env_name = "MCP_TEST_REMOTE_SOURCE_ONLY"; + + mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + &namespace, + "echo", + &format!(r#"{{"message":"ping","env_var":"{env_name}"}}"#), + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let _guard = EnvVarGuard::set(env_name, OsStr::new("local-value-should-not-cross")); + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport( + rmcp_test_server_bin, + /*env*/ None, + vec![McpServerEnvVar::Config { + name: env_name.to_string(), + source: Some("remote".to_string()), + }], + ), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, + ); + }) + .build_remote_aware(&server) + .await?; + + let session_model = fixture.session_configured.model.clone(); + fixture + .codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "call the rmcp echo tool".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) + .await; + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + let structured = end + .result + .as_ref() + .expect("rmcp echo tool should return success") + .structured_content + .as_ref() + .expect("structured content"); + assert_eq!(structured["env"], Value::Null); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index aa5ab5eee6..b81c224a44 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -15,6 +15,7 @@ axum = { workspace = true, default-features = false, features = [ ] } codex-client = { workspace = true } codex-config = { workspace = true } +codex-exec-server = { workspace = true } codex-keyring-store = { workspace = true } codex-protocol = { workspace = true } codex-utils-pty = { workspace = true } diff --git a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs index e513f12780..5bf32124d0 100644 --- a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs +++ b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs @@ -74,7 +74,6 @@ impl TestToolServer { #[derive(Deserialize)] struct EchoArgs { message: String, - #[allow(dead_code)] env_var: Option, } @@ -125,9 +124,10 @@ impl ServerHandler for TestToolServer { }; let env_snapshot: HashMap = std::env::vars().collect(); + let env_name = args.env_var.as_deref().unwrap_or("MCP_TEST_VALUE"); let structured_content = json!({ "echo": args.message, - "env": env_snapshot.get("MCP_TEST_VALUE"), + "env": env_snapshot.get(env_name), }); Ok(CallToolResult { diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 82bc387d99..4d3a2ba775 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -68,6 +68,7 @@ impl TestToolServer { let tools = vec![ Self::echo_tool(), Self::echo_dash_tool(), + Self::cwd_tool(), Self::sync_tool(), Self::image_tool(), Self::image_scenario_tool(), @@ -124,7 +125,7 @@ impl TestToolServer { { "type": "string" }, { "type": "null" } ] - } + }, }, "required": ["echo", "env"], "additionalProperties": false @@ -135,6 +136,35 @@ impl TestToolServer { tool } + fn cwd_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("cwd tool schema should deserialize"); + + let mut tool = Tool::new( + Cow::Borrowed("cwd"), + Cow::Borrowed("Return the current working directory of this test server process."), + Arc::new(schema), + ); + #[expect(clippy::expect_used)] + let output_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "cwd": { "type": "string" } + }, + "required": ["cwd"], + "additionalProperties": false + })) + .expect("cwd tool output schema should deserialize"); + tool.output_schema = Some(Arc::new(output_schema)); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool + } + fn sync_tool() -> Tool { #[expect(clippy::expect_used)] let schema: JsonObject = serde_json::from_value(json!({ @@ -292,7 +322,6 @@ impl TestToolServer { #[derive(Deserialize)] struct EchoArgs { message: String, - #[allow(dead_code)] env_var: Option, } @@ -453,6 +482,17 @@ impl ServerHandler for TestToolServer { is_error: Some(false), meta: None, }), + "cwd" => { + let cwd = std::env::current_dir() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + Ok(CallToolResult { + content: Vec::new(), + structured_content: Some(json!({ "cwd": cwd })), + is_error: Some(false), + meta: None, + }) + } "echo" | "echo-tool" => { let args: EchoArgs = match request.arguments { Some(arguments) => serde_json::from_value(serde_json::Value::Object( @@ -468,9 +508,10 @@ impl ServerHandler for TestToolServer { }; let env_snapshot: HashMap = std::env::vars().collect(); + let env_name = args.env_var.as_deref().unwrap_or("MCP_TEST_VALUE"); let structured_content = json!({ "echo": format!("ECHOING: {}", args.message), - "env": env_snapshot.get("MCP_TEST_VALUE"), + "env": env_snapshot.get(env_name), }); Ok(CallToolResult { diff --git a/codex-rs/rmcp-client/src/executor_process_transport.rs b/codex-rs/rmcp-client/src/executor_process_transport.rs new file mode 100644 index 0000000000..41f0b7660d --- /dev/null +++ b/codex-rs/rmcp-client/src/executor_process_transport.rs @@ -0,0 +1,376 @@ +//! rmcp transport adapter for an executor-managed MCP stdio process. +//! +//! This module owns the lower-level byte translation after +//! `stdio_server_launcher` has already started a process through +//! `ExecBackend::start`. It does not choose where the MCP server runs and it +//! does not implement MCP lifecycle behavior. MCP protocol ownership stays in +//! `RmcpClient` and rmcp: +//! +//! 1. rmcp serializes a JSON-RPC message and calls [`Transport::send`]. +//! 2. This transport appends the stdio newline delimiter and writes those bytes +//! to executor `process/write`. +//! 3. The executor writes the bytes to the child process stdin. +//! 4. The child writes newline-delimited JSON-RPC messages to stdout. +//! 5. The executor reports stdout bytes through pushed process events. +//! 6. This transport buffers stdout until it has one full line, deserializes +//! that line, and returns the rmcp message from [`Transport::receive`]. +//! +//! Stderr is deliberately not part of the MCP byte stream. It is logged for +//! diagnostics only, matching the local stdio implementation. + +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 codex_exec_server::ExecOutputStream; +use codex_exec_server::ExecProcess; +use codex_exec_server::ExecProcessEvent; +use codex_exec_server::ExecProcessEventReceiver; +use codex_exec_server::ProcessId; +use codex_exec_server::ProcessOutputChunk; +use codex_exec_server::WriteStatus; +use rmcp::service::RoleClient; +use rmcp::service::RxJsonRpcMessage; +use rmcp::service::TxJsonRpcMessage; +use rmcp::transport::Transport; +use serde_json::from_slice; +use serde_json::to_vec; +use tokio::runtime::Handle; +use tokio::sync::broadcast; +use tracing::debug; +use tracing::info; +use tracing::warn; + +static PROCESS_COUNTER: AtomicUsize = AtomicUsize::new(1); + +// Remote public implementation. + +/// A client-side rmcp transport backed by an executor-managed process. +/// +/// The orchestrator owns this value and calls rmcp on it. The process it wraps +/// may be local or remote depending on the `ExecBackend` used to create it, but +/// for remote MCP stdio the process lives on the executor and all interaction +/// crosses the executor process RPC boundary. +pub(super) struct ExecutorProcessTransport { + /// Logical process handle returned by the executor process API. + /// + /// `write` forwards stdin bytes. `terminate` stops the child when rmcp + /// closes the transport. + process: Arc, + + /// Pushed output/lifecycle stream for the process. + /// + /// The executor process API still supports retained-output reads, but MCP + /// stdio is naturally streaming. This receiver lets rmcp wait for stdout + /// chunks without issuing `process/read` after each output notification. + events: ExecProcessEventReceiver, + + /// Human-readable program name used only in diagnostics. + program_name: String, + + /// Buffered child stdout bytes that have not yet formed a complete + /// newline-delimited JSON-RPC message. + stdout: Vec, + + /// Buffered stderr bytes for diagnostic logging. + stderr: Vec, + + /// Whether the executor has reported process closure or a terminal + /// subscription failure. Once closed, any remaining partial stdout line is + /// flushed once and then rmcp receives EOF. + closed: bool, + + /// Whether this transport already asked the executor to terminate the MCP + /// server process. + terminated: bool, + + /// Highest executor process event sequence observed by this transport. + /// + /// When the pushed event stream lags, use this as the retained-output read + /// cursor to recover missed stdout/stderr chunks from the executor. + last_seq: u64, +} + +impl ExecutorProcessTransport { + pub(super) fn new(process: Arc, program_name: String) -> Self { + // Subscribe before returning the transport to rmcp. Some test servers + // can emit output or exit quickly after `process/start`, and the + // process event log will replay anything that landed before this + // subscriber was attached. + let events = process.subscribe_events(); + Self { + process, + events, + program_name, + stdout: Vec::new(), + stderr: Vec::new(), + closed: false, + terminated: false, + last_seq: 0, + } + } + + pub(super) fn next_process_id() -> ProcessId { + // Process IDs are logical handles scoped to the executor connection, + // not OS pids. A monotonic client-side id is enough to avoid + // collisions between MCP servers started in the same session. + let index = PROCESS_COUNTER.fetch_add(1, Ordering::Relaxed); + ProcessId::from(format!("mcp-stdio-{index}")) + } +} + +impl Transport for ExecutorProcessTransport { + type Error = io::Error; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let process = Arc::clone(&self.process); + async move { + // rmcp hands us a structured JSON-RPC message. Stdio transport on + // the wire is JSON plus one newline delimiter. + let mut bytes = to_vec(&item).map_err(io::Error::other)?; + bytes.push(b'\n'); + let response = process.write(bytes).await.map_err(io::Error::other)?; + match response.status { + WriteStatus::Accepted => Ok(()), + WriteStatus::UnknownProcess => { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "unknown process")) + } + WriteStatus::StdinClosed => { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin closed")) + } + WriteStatus::Starting => Err(io::Error::new( + io::ErrorKind::WouldBlock, + "process is starting", + )), + } + } + } + + fn receive(&mut self) -> impl Future>> + Send { + self.receive_message() + } + + async fn close(&mut self) -> std::result::Result<(), Self::Error> { + self.process.terminate().await.map_err(io::Error::other)?; + self.terminated = true; + Ok(()) + } +} + +impl ExecutorProcessTransport { + async fn receive_message(&mut self) -> Option> { + loop { + // rmcp stdio framing is line-oriented JSON. We first drain any + // complete line already buffered from an earlier process event. + if let Some(message) = self.take_stdout_message(/*allow_partial*/ self.closed) { + return Some(message); + } + if self.closed { + self.flush_stderr(); + return None; + } + + match self.events.recv().await { + Ok(ExecProcessEvent::Output(chunk)) => { + // The executor pushes raw process bytes. This is the only + // place where those bytes are split back into the stdout + // protocol stream and stderr diagnostics. + self.push_process_output_if_new(chunk); + } + Ok(ExecProcessEvent::Exited { seq, .. }) => { + self.note_seq(seq); + // Wait for `Closed` before ending the rmcp stream so any + // output flushed during process shutdown can still be + // decoded into JSON-RPC messages. + } + Ok(ExecProcessEvent::Closed { seq }) => { + self.note_seq(seq); + self.closed = true; + } + Ok(ExecProcessEvent::Failed(message)) => { + warn!( + "Remote MCP server process failed ({}): {message}", + self.program_name + ); + self.closed = true; + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!( + "Remote MCP server output stream lagged ({}): skipped {skipped} events", + self.program_name + ); + if let Err(error) = self.recover_lagged_events().await { + warn!( + "Failed to recover remote MCP server output stream ({}): {error}", + self.program_name + ); + self.closed = true; + } + } + Err(broadcast::error::RecvError::Closed) => { + self.closed = true; + } + } + } + } + + fn note_seq(&mut self, seq: u64) { + self.last_seq = self.last_seq.max(seq); + } + + fn should_accept_seq(&mut self, seq: u64) -> bool { + if seq <= self.last_seq { + return false; + } + self.last_seq = seq; + true + } + + async fn recover_lagged_events(&mut self) -> io::Result<()> { + let response = self + .process + .read( + Some(self.last_seq), + /*max_bytes*/ None, + /*wait_ms*/ Some(0), + ) + .await + .map_err(io::Error::other)?; + for chunk in response.chunks { + self.push_process_output_if_new(chunk); + } + self.last_seq = self.last_seq.max(response.next_seq.saturating_sub(1)); + if let Some(message) = response.failure { + warn!( + "Remote MCP server process failed ({}): {message}", + self.program_name + ); + self.closed = true; + } else if response.closed { + self.closed = true; + } + Ok(()) + } + + fn push_process_output_if_new(&mut self, chunk: ProcessOutputChunk) { + if !self.should_accept_seq(chunk.seq) { + return; + } + self.push_process_output(chunk); + } + + fn push_process_output(&mut self, chunk: ProcessOutputChunk) { + let bytes = chunk.chunk.into_inner(); + match chunk.stream { + // MCP stdio uses stdout as the protocol stream. PTY output is + // accepted defensively because the executor process API has a + // unified stream enum, but remote MCP starts with `tty=false`. + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + self.stdout.extend_from_slice(&bytes); + } + // Stderr is intentionally out-of-band. It should help debug server + // startup failures without entering rmcp framing. + ExecOutputStream::Stderr => { + self.push_stderr(&bytes); + } + } + } + + fn take_stdout_message(&mut self, allow_partial: bool) -> Option> { + // A normal MCP stdio server emits one JSON-RPC message per newline. + // If the process has already closed, accept a final unterminated line + // 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 = Self::trim_trailing_carriage_return(line); + match from_slice::>(&line) { + Ok(message) => return Some(message), + Err(error) => { + debug!( + "Failed to parse remote MCP server message ({}): {error}", + self.program_name + ); + } + } + } + } + + fn push_stderr(&mut self, bytes: &[u8]) { + // 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(); + } + info!( + "MCP server stderr ({}): {}", + self.program_name, + String::from_utf8_lossy(&line) + ); + } + } + + fn flush_stderr(&mut self) { + if self.stderr.is_empty() { + return; + } + let line = take(&mut self.stderr); + info!( + "MCP server stderr ({}): {}", + self.program_name, + String::from_utf8_lossy(&line) + ); + } + + fn trim_trailing_carriage_return(mut line: Vec) -> Vec { + if line.last() == Some(&b'\r') { + line.pop(); + } + line + } +} + +impl Drop for ExecutorProcessTransport { + fn drop(&mut self) { + if self.terminated { + return; + } + + let process = Arc::clone(&self.process); + let program_name = self.program_name.clone(); + let Ok(handle) = Handle::try_current() else { + warn!( + "Could not schedule remote MCP server process termination on drop ({}): no Tokio runtime is available", + self.program_name + ); + return; + }; + + std::mem::drop(handle.spawn(async move { + if let Err(error) = process.terminate().await { + warn!( + "Failed to terminate remote MCP server process on drop ({program_name}): {error}" + ); + } + })); + } +} diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index f02167b6f6..f3870b9319 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -1,5 +1,6 @@ mod auth_status; mod elicitation_client_service; +mod executor_process_transport; mod logging_client_handler; mod oauth; mod perform_oauth_login; @@ -30,5 +31,6 @@ pub use rmcp_client::ListToolsWithConnectorIdResult; pub use rmcp_client::RmcpClient; pub use rmcp_client::SendElicitation; pub use rmcp_client::ToolWithConnectorId; +pub use stdio_server_launcher::ExecutorStdioServerLauncher; pub use stdio_server_launcher::LocalStdioServerLauncher; pub use stdio_server_launcher::StdioServerLauncher; diff --git a/codex-rs/rmcp-client/src/program_resolver.rs b/codex-rs/rmcp-client/src/program_resolver.rs index d9be2741c7..c20cac3747 100644 --- a/codex-rs/rmcp-client/src/program_resolver.rs +++ b/codex-rs/rmcp-client/src/program_resolver.rs @@ -157,7 +157,7 @@ mod tests { #[cfg(windows)] extra_env.insert(OsString::from("PATHEXT"), Self::ensure_cmd_extension()); - let mcp_env = create_env_for_mcp_server(Some(extra_env), &[]); + let mcp_env = create_env_for_mcp_server(Some(extra_env), &[])?; Ok(Self { _temp_dir: temp_dir, diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 6bdc5dd29f..b80e335a59 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -13,6 +13,7 @@ use std::time::Instant; use anyhow::Result; use anyhow::anyhow; use codex_client::build_reqwest_client_with_custom_ca; +use codex_config::types::McpServerEnvVar; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; @@ -503,7 +504,7 @@ impl RmcpClient { program: OsString, args: Vec, env: Option>, - env_vars: &[String], + env_vars: &[McpServerEnvVar], cwd: Option, launcher: Arc, ) -> io::Result { diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 2fd610e5cb..4e9020e369 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -1,12 +1,15 @@ //! Launch MCP stdio servers and return the transport rmcp should use. //! -//! This module owns the "where does the server process run?" boundary for -//! stdio MCP servers. In this PR there is only the local launcher, which keeps -//! the existing behavior: the orchestrator starts the configured command and -//! rmcp talks to the child process through local stdin/stdout pipes. +//! This module owns the "where does the server process run?" decision: //! -//! Later stack entries add an executor-backed launcher without changing -//! `RmcpClient`'s MCP lifecycle code. +//! - [`LocalStdioServerLauncher`] starts the configured command as a child of +//! the orchestrator process. +//! - [`ExecutorStdioServerLauncher`] starts the configured command through the +//! executor process API. +//! +//! Both paths return [`StdioServerTransport`], so `RmcpClient` can hand the +//! resulting byte stream to rmcp without knowing where the process lives. The +//! executor-specific byte adaptation lives in `executor_process_transport`. use std::collections::HashMap; use std::ffi::OsString; @@ -14,6 +17,7 @@ use std::future::Future; use std::io; use std::path::PathBuf; use std::process::Stdio; +use std::sync::Arc; #[cfg(unix)] use std::thread::sleep; #[cfg(unix)] @@ -21,6 +25,13 @@ use std::thread::spawn; #[cfg(unix)] use std::time::Duration; +use anyhow::Result; +use anyhow::anyhow; +use codex_config::types::McpServerEnvVar; +use codex_config::types::ShellEnvironmentPolicyInherit; +use codex_exec_server::ExecBackend; +use codex_exec_server::ExecEnvPolicy; +use codex_exec_server::ExecParams; #[cfg(unix)] use codex_utils_pty::process_group::kill_process_group; #[cfg(unix)] @@ -38,8 +49,11 @@ use tokio::process::Command; use tracing::info; use tracing::warn; +use crate::executor_process_transport::ExecutorProcessTransport; use crate::program_resolver; use crate::utils::create_env_for_mcp_server; +use crate::utils::create_env_overlay_for_remote_mcp_server; +use crate::utils::remote_mcp_env_var_names; // General purpose public code. @@ -63,7 +77,7 @@ pub struct StdioServerCommand { program: OsString, args: Vec, env: Option>, - env_vars: Vec, + env_vars: Vec, cwd: Option, } @@ -74,11 +88,16 @@ pub struct StdioServerCommand { /// directly to `rmcp::service::serve_client`. pub struct StdioServerTransport { inner: StdioServerTransportInner, + // Local child processes can leave subprocesses behind, so the local + // variant keeps a process-group guard with the transport. Executor-backed + // processes are owned and cleaned up by the executor, so that variant uses + // `None`. _process_group_guard: Option, } enum StdioServerTransportInner { Local(TokioChildProcess), + Executor(ExecutorProcessTransport), } impl Transport for StdioServerTransport { @@ -88,20 +107,29 @@ impl Transport for StdioServerTransport { &mut self, item: TxJsonRpcMessage, ) -> impl Future> + Send + 'static { + // Both variants already implement rmcp's transport contract. This + // wrapper keeps process placement private while leaving rmcp's send + // semantics unchanged. match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.send(item).boxed(), + StdioServerTransportInner::Executor(transport) => transport.send(item).boxed(), } } fn receive(&mut self) -> impl Future>> + Send { + // rmcp reads from the same transport shape for both placements. The + // executor variant turns pushed process-output events back into the + // line-delimited JSON stream expected by rmcp. match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.receive().boxed(), + StdioServerTransportInner::Executor(transport) => transport.receive().boxed(), } } async fn close(&mut self) -> std::result::Result<(), Self::Error> { match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.close().await, + StdioServerTransportInner::Executor(transport) => transport.close().await, } } } @@ -113,7 +141,7 @@ impl StdioServerCommand { program: OsString, args: Vec, env: Option>, - env_vars: Vec, + env_vars: Vec, cwd: Option, ) -> Self { Self { @@ -174,7 +202,7 @@ impl LocalStdioServerLauncher { cwd, } = command; let program_name = program.to_string_lossy().into_owned(); - let envs = create_env_for_mcp_server(env, &env_vars); + let envs = create_env_for_mcp_server(env, &env_vars).map_err(io::Error::other)?; let resolved_program = program_resolver::resolve(program, &envs).map_err(io::Error::other)?; @@ -266,3 +294,229 @@ impl Drop for ProcessGroupGuard { } } } + +// Remote public implementation. + +/// Starts MCP stdio servers through the executor process API. +/// +/// MCP framing still runs in the orchestrator. The executor only owns the +/// child process and transports raw stdin/stdout/stderr bytes, so it does not +/// need to know about MCP methods such as `initialize` or `tools/list`. +#[derive(Clone)] +pub struct ExecutorStdioServerLauncher { + exec_backend: Arc, + fallback_cwd: PathBuf, +} + +impl ExecutorStdioServerLauncher { + /// Creates a stdio server launcher backed by the executor process API. + /// + /// `fallback_cwd` is used only when the MCP server config omits `cwd`. + /// Executor `process/start` requires an explicit working directory, unlike + /// local `tokio::process::Command`, which can inherit the orchestrator cwd. + pub fn new(exec_backend: Arc, fallback_cwd: PathBuf) -> Self { + Self { + exec_backend, + fallback_cwd, + } + } +} + +impl StdioServerLauncher for ExecutorStdioServerLauncher { + fn launch( + &self, + command: StdioServerCommand, + ) -> BoxFuture<'static, io::Result> { + let exec_backend = Arc::clone(&self.exec_backend); + let fallback_cwd = self.fallback_cwd.clone(); + async move { Self::launch_server(command, exec_backend, fallback_cwd).await }.boxed() + } +} + +// Remote private implementation. + +impl private::Sealed for ExecutorStdioServerLauncher {} + +impl ExecutorStdioServerLauncher { + async fn launch_server( + command: StdioServerCommand, + exec_backend: Arc, + fallback_cwd: PathBuf, + ) -> io::Result { + let StdioServerCommand { + program, + args, + env, + env_vars, + cwd, + } = command; + let program_name = program.to_string_lossy().into_owned(); + let envs = create_env_overlay_for_remote_mcp_server(env, &env_vars); + let remote_env_vars = remote_mcp_env_var_names(&env_vars); + // The executor protocol carries argv/env as UTF-8 strings. Local stdio can + // accept arbitrary OsString values because it calls the OS directly; remote + // stdio must reject non-Unicode command, argument, or environment data + // before sending an executor request. + let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?; + let env = Self::process_api_env(envs).map_err(io::Error::other)?; + let process_id = ExecutorProcessTransport::next_process_id(); + // Start the MCP server process on the executor with raw pipes. `tty=false` + // keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets + // rmcp write JSON-RPC requests after the process starts. + let started = exec_backend + .start(ExecParams { + process_id, + argv, + cwd: cwd.unwrap_or(fallback_cwd), + env_policy: Some(Self::remote_env_policy(&remote_env_vars)), + env, + tty: false, + pipe_stdin: true, + arg0: None, + }) + .await + .map_err(io::Error::other)?; + + Ok(StdioServerTransport { + inner: StdioServerTransportInner::Executor(ExecutorProcessTransport::new( + started.process, + program_name, + )), + _process_group_guard: None, + }) + } + + fn process_api_argv(program: &OsString, args: &[OsString]) -> Result> { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push(Self::os_string_to_process_api_string( + program.clone(), + "command", + )?); + for arg in args { + argv.push(Self::os_string_to_process_api_string( + arg.clone(), + "argument", + )?); + } + Ok(argv) + } + + fn process_api_env(env: HashMap) -> Result> { + env.into_iter() + .map(|(key, value)| { + Ok(( + Self::os_string_to_process_api_string(key, "environment variable name")?, + Self::os_string_to_process_api_string(value, "environment variable value")?, + )) + }) + .collect() + } + + fn os_string_to_process_api_string(value: OsString, label: &str) -> Result { + value + .into_string() + .map_err(|_| anyhow!("{label} must be valid Unicode for remote MCP stdio")) + } + + fn remote_env_policy(remote_env_vars: &[String]) -> ExecEnvPolicy { + let include_only = if remote_env_vars.is_empty() { + Vec::new() + } else { + // `source = "remote"` means the value is read from the executor's + // environment, not copied from Codex. Start from `All` only so the + // named remote variable is available to the filter below; the + // effective child env is still limited by `include_only`. + crate::utils::DEFAULT_ENV_VARS + .iter() + .map(|name| (*name).to_string()) + .chain(remote_env_vars.iter().cloned()) + .collect() + }; + ExecEnvPolicy { + inherit: if remote_env_vars.is_empty() { + ShellEnvironmentPolicyInherit::Core + } else { + ShellEnvironmentPolicyInherit::All + }, + ignore_default_excludes: true, + exclude: Vec::new(), + r#set: HashMap::new(), + include_only, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_config::shell_environment; + use codex_config::types::EnvironmentVariablePattern; + use codex_config::types::ShellEnvironmentPolicy; + + #[test] + fn remote_env_policy_uses_core_env_without_remote_source_vars() { + let policy = ExecutorStdioServerLauncher::remote_env_policy(&[]); + + assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::Core); + assert!(policy.include_only.is_empty()); + } + + #[test] + fn remote_env_policy_includes_remote_source_vars_without_full_env() { + let policy = ExecutorStdioServerLauncher::remote_env_policy(&["REMOTE_TOKEN".to_string()]); + + assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::All); + assert!( + policy.include_only.contains(&"REMOTE_TOKEN".to_string()), + "remote source var should be included in executor env policy" + ); + assert!( + policy + .include_only + .contains(&crate::utils::DEFAULT_ENV_VARS[0].to_string()), + "remote default env vars should remain available" + ); + } + + #[test] + fn remote_env_policy_effectively_filters_unrequested_vars() { + let exec_policy = + ExecutorStdioServerLauncher::remote_env_policy(&["REMOTE_TOKEN".to_string()]); + let policy = ShellEnvironmentPolicy { + inherit: exec_policy.inherit, + ignore_default_excludes: exec_policy.ignore_default_excludes, + exclude: exec_policy + .exclude + .iter() + .map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern)) + .collect(), + r#set: exec_policy.r#set, + include_only: exec_policy + .include_only + .iter() + .map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern)) + .collect(), + use_profile: false, + }; + + let env = shell_environment::create_env_from_vars( + [ + ("PATH".to_string(), "/remote/bin".to_string()), + ("REMOTE_TOKEN".to_string(), "remote-secret".to_string()), + ( + "UNREQUESTED_SECRET".to_string(), + "must-not-pass".to_string(), + ), + ], + &policy, + /*thread_id*/ None, + ); + + assert_eq!(env.get("PATH").map(String::as_str), Some("/remote/bin")); + assert_eq!( + env.get("REMOTE_TOKEN").map(String::as_str), + Some("remote-secret") + ); + assert!(!env.contains_key("UNREQUESTED_SECRET")); + } +} diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index cf8d4cbb26..6176f071ce 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -1,4 +1,6 @@ use anyhow::Result; +use anyhow::anyhow; +use codex_config::types::McpServerEnvVar; use reqwest::ClientBuilder; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; @@ -9,17 +11,52 @@ use std::ffi::OsString; pub(crate) fn create_env_for_mcp_server( extra_env: Option>, - env_vars: &[String], -) -> HashMap { - DEFAULT_ENV_VARS + env_vars: &[McpServerEnvVar], +) -> Result> { + let additional_env_vars = local_stdio_env_var_names(env_vars)?; + let env = DEFAULT_ENV_VARS .iter() .copied() - .chain(env_vars.iter().map(String::as_str)) + .chain(additional_env_vars) .filter_map(|var| env::var_os(var).map(|value| (OsString::from(var), value))) .chain(extra_env.unwrap_or_default()) + .collect(); + Ok(env) +} + +pub(crate) fn create_env_overlay_for_remote_mcp_server( + extra_env: Option>, + env_vars: &[McpServerEnvVar], +) -> HashMap { + // Remote stdio should inherit PATH/HOME/etc. from the executor side, not + // from the orchestrator process. Only forward variables explicitly named + // by the MCP config plus literal env overrides from that config. + env_vars + .iter() + .filter(|var| !var.is_remote_source()) + .filter_map(|var| env::var_os(var.name()).map(|value| (OsString::from(var.name()), value))) + .chain(extra_env.unwrap_or_default()) .collect() } +pub(crate) fn remote_mcp_env_var_names(env_vars: &[McpServerEnvVar]) -> Vec { + env_vars + .iter() + .filter(|var| var.is_remote_source()) + .map(|var| var.name().to_string()) + .collect() +} + +fn local_stdio_env_var_names(env_vars: &[McpServerEnvVar]) -> Result> { + if let Some(remote_var) = env_vars.iter().find(|var| var.is_remote_source()) { + return Err(anyhow!( + "env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", + remote_var.name() + )); + } + Ok(env_vars.iter().map(McpServerEnvVar::name)) +} + pub(crate) fn build_default_headers( http_headers: Option>, env_http_headers: Option>, @@ -182,7 +219,8 @@ mod tests { let env = create_env_for_mcp_server( Some(HashMap::from([(OsString::from("TZ"), expected.clone())])), &[], - ); + ) + .expect("local MCP env should build"); assert_eq!(env.get(OsStr::new("TZ")), Some(&expected)); } @@ -193,10 +231,92 @@ mod tests { let value = "from-env"; let expected = OsString::from(value); let _guard = EnvVarGuard::set(custom_var, value); - let env = create_env_for_mcp_server(/*extra_env*/ None, &[custom_var.to_string()]); + let env = create_env_for_mcp_server(/*extra_env*/ None, &[custom_var.into()]) + .expect("local MCP env should build"); assert_eq!(env.get(OsStr::new(custom_var)), Some(&expected)); } + #[test] + #[serial(extra_rmcp_env)] + fn create_remote_env_overlay_only_forwards_explicit_variables() { + let default_var = DEFAULT_ENV_VARS[0]; + let custom_var = "EXTRA_REMOTE_RMCP_ENV"; + let custom_value = OsString::from("from-env"); + let _default_guard = EnvVarGuard::set(default_var, "from-default"); + let _custom_guard = EnvVarGuard::set(custom_var, &custom_value); + + let env = + create_env_overlay_for_remote_mcp_server(/*extra_env*/ None, &[custom_var.into()]); + + assert_eq!( + env, + HashMap::from([(OsString::from(custom_var), custom_value)]) + ); + } + + #[test] + #[serial(extra_rmcp_env)] + fn create_remote_env_overlay_does_not_copy_remote_source_variables() { + let remote_var = "REMOTE_ONLY_RMCP_ENV"; + let local_var = "LOCAL_RMCP_ENV"; + let local_value = OsString::from("from-local-env"); + let _remote_guard = EnvVarGuard::set(remote_var, "should-not-be-copied"); + let _local_guard = EnvVarGuard::set(local_var, &local_value); + + let env = create_env_overlay_for_remote_mcp_server( + /*extra_env*/ None, + &[ + McpServerEnvVar::Config { + name: remote_var.to_string(), + source: Some("remote".to_string()), + }, + McpServerEnvVar::Config { + name: local_var.to_string(), + source: Some("local".to_string()), + }, + ], + ); + + assert_eq!( + env, + HashMap::from([(OsString::from(local_var), local_value)]) + ); + } + + #[test] + fn remote_mcp_env_var_names_returns_remote_source_names() { + let names = remote_mcp_env_var_names(&[ + "LEGACY".into(), + McpServerEnvVar::Config { + name: "LOCAL".to_string(), + source: Some("local".to_string()), + }, + McpServerEnvVar::Config { + name: "REMOTE".to_string(), + source: Some("remote".to_string()), + }, + ]); + + assert_eq!(names, vec!["REMOTE".to_string()]); + } + + #[test] + fn create_local_env_rejects_remote_source_variables() { + let err = create_env_for_mcp_server( + /*extra_env*/ None, + &[McpServerEnvVar::Config { + name: "REMOTE".to_string(), + source: Some("remote".to_string()), + }], + ) + .expect_err("remote source should require remote stdio"); + + assert!( + err.to_string().contains("requires remote MCP stdio"), + "unexpected error: {err}" + ); + } + #[cfg(unix)] #[test] #[serial(extra_rmcp_env)] @@ -207,7 +327,8 @@ mod tests { let expected = raw_path.to_os_string(); let _guard = EnvVarGuard::set("PATH", raw_path); - let env = create_env_for_mcp_server(/*extra_env*/ None, &[]); + let env = + create_env_for_mcp_server(/*extra_env*/ None, &[]).expect("local MCP env should build"); assert_eq!(env.get(OsStr::new("PATH")), Some(&expected)); } diff --git a/codex-rs/utils/cli/src/format_env_display.rs b/codex-rs/utils/cli/src/format_env_display.rs index dda0cad099..cee63bb32b 100644 --- a/codex-rs/utils/cli/src/format_env_display.rs +++ b/codex-rs/utils/cli/src/format_env_display.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -pub fn format_env_display(env: Option<&HashMap>, env_vars: &[String]) -> String { +pub fn format_env_display>( + env: Option<&HashMap>, + env_vars: &[S], +) -> String { let mut parts: Vec = Vec::new(); if let Some(map) = env { @@ -10,7 +13,7 @@ pub fn format_env_display(env: Option<&HashMap>, env_vars: &[Str } if !env_vars.is_empty() { - parts.extend(env_vars.iter().map(|var| format!("{var}=*****"))); + parts.extend(env_vars.iter().map(|var| format!("{}=*****", var.as_ref()))); } if parts.is_empty() { @@ -26,10 +29,11 @@ mod tests { #[test] fn returns_dash_when_empty() { - assert_eq!(format_env_display(/*env*/ None, &[]), "-"); + let empty_vars: &[String] = &[]; + assert_eq!(format_env_display(/*env*/ None, empty_vars), "-"); let empty_map = HashMap::new(); - assert_eq!(format_env_display(Some(&empty_map), &[]), "-"); + assert_eq!(format_env_display(Some(&empty_map), empty_vars), "-"); } #[test] @@ -38,7 +42,10 @@ mod tests { env.insert("B".to_string(), "two".to_string()); env.insert("A".to_string(), "one".to_string()); - assert_eq!(format_env_display(Some(&env), &[]), "A=*****, B=*****"); + assert_eq!( + format_env_display(Some(&env), &[] as &[String]), + "A=*****, B=*****" + ); } #[test]