Compare commits

..

3 Commits

Author SHA1 Message Date
jif-oai
2b914de0a8 Merge branch 'main' into jif/remove-list-dir 2026-03-25 16:02:00 +00:00
jif-oai
178c3b15b4 chore: remove grep_files handler (#15775)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 16:01:45 +00:00
jif-oai
782a99cde5 chore: remove list_dir handler
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 15:01:30 +00:00
41 changed files with 381 additions and 1633 deletions

32
codex-rs/Cargo.lock generated
View File

@@ -1883,11 +1883,9 @@ dependencies = [
"codex-features",
"codex-git-utils",
"codex-hooks",
"codex-instructions",
"codex-login",
"codex-network-proxy",
"codex-otel",
"codex-plugin",
"codex-protocol",
"codex-rmcp-client",
"codex-rollout",
@@ -1906,7 +1904,6 @@ dependencies = [
"codex-utils-image",
"codex-utils-output-truncation",
"codex-utils-path",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-stream-parser",
@@ -2177,15 +2174,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-instructions"
version = "0.0.0"
dependencies = [
"codex-protocol",
"pretty_assertions",
"serde",
]
[[package]]
name = "codex-keyring-store"
version = "0.0.0"
@@ -2395,17 +2383,6 @@ dependencies = [
"zip",
]
[[package]]
name = "codex-plugin"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"codex-utils-plugins",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "codex-process-hardening"
version = "0.0.0"
@@ -2960,15 +2937,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "codex-utils-plugins"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "codex-utils-pty"
version = "0.0.0"

View File

@@ -25,7 +25,6 @@ members = [
"skills",
"core",
"hooks",
"instructions",
"secrets",
"exec",
"exec-server",
@@ -69,7 +68,6 @@ members = [
"utils/oss",
"utils/output-truncation",
"utils/path-utils",
"utils/plugins",
"utils/fuzzy-match",
"utils/stream-parser",
"codex-client",
@@ -79,7 +77,6 @@ members = [
"codex-experimental-api-macros",
"test-macros",
"package-manager",
"plugin",
"artifacts",
]
resolver = "2"
@@ -125,7 +122,6 @@ codex-features = { path = "features" }
codex-file-search = { path = "file-search" }
codex-git-utils = { path = "git-utils" }
codex-hooks = { path = "hooks" }
codex-instructions = { path = "instructions" }
codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
@@ -134,7 +130,6 @@ codex-mcp-server = { path = "mcp-server" }
codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-plugin = { path = "plugin" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-rollout = { path = "rollout" }
@@ -165,7 +160,6 @@ codex-utils-json-to-toml = { path = "utils/json-to-toml" }
codex-utils-oss = { path = "utils/oss" }
codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
codex-utils-plugins = { path = "utils/plugins" }
codex-utils-pty = { path = "utils/pty" }
codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }

View File

@@ -42,11 +42,9 @@ codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-instructions = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-artifacts = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-rollout = { workspace = true }
codex-rmcp-client = { workspace = true }
@@ -59,7 +57,6 @@ codex-utils-image = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-output-truncation = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-plugins = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-secrets = { workspace = true }

View File

@@ -1,12 +1,14 @@
use codex_instructions::AGENTS_MD_FRAGMENT;
use codex_instructions::ContextualUserFragmentDefinition;
use codex_instructions::SKILL_FRAGMENT;
use codex_protocol::items::HookPromptItem;
use codex_protocol::items::parse_hook_prompt_fragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
@@ -14,11 +16,64 @@ pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
#[derive(Clone, Copy)]
pub(crate) struct ContextualUserFragmentDefinition {
start_marker: &'static str,
end_marker: &'static str,
}
impl ContextualUserFragmentDefinition {
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
Self {
start_marker,
end_marker,
}
}
pub(crate) fn matches_text(&self, text: &str) -> bool {
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..self.start_marker.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
starts_with_marker && ends_with_marker
}
pub(crate) const fn start_marker(&self) -> &'static str {
self.start_marker
}
pub(crate) const fn end_marker(&self) -> &'static str {
self.end_marker
}
pub(crate) fn wrap(&self, body: String) -> String {
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
}
pub(crate) fn into_message(self, text: String) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
end_turn: None,
phase: None,
}
}
}
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(
ENVIRONMENT_CONTEXT_OPEN_TAG,
ENVIRONMENT_CONTEXT_CLOSE_TAG,
);
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(
USER_SHELL_COMMAND_OPEN_TAG,

View File

@@ -1,7 +1,6 @@
use super::*;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::ResponseItem;
#[test]
fn detects_environment_context_fragment() {

View File

@@ -1,3 +1,5 @@
pub(crate) use codex_instructions::SkillInstructions;
pub use codex_instructions::USER_INSTRUCTIONS_PREFIX;
pub(crate) use codex_instructions::UserInstructions;
mod user_instructions;
pub(crate) use user_instructions::SkillInstructions;
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
pub(crate) use user_instructions::UserInstructions;

View File

@@ -3,21 +3,20 @@ use serde::Serialize;
use codex_protocol::models::ResponseItem;
use crate::fragment::AGENTS_MD_FRAGMENT;
use crate::fragment::AGENTS_MD_START_MARKER;
use crate::fragment::SKILL_FRAGMENT;
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
use crate::contextual_user_message::SKILL_FRAGMENT;
pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER;
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
pub struct UserInstructions {
pub(crate) struct UserInstructions {
pub directory: String,
pub text: String,
}
impl UserInstructions {
pub fn serialize_to_text(&self) -> String {
pub(crate) fn serialize_to_text(&self) -> String {
format!(
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
prefix = AGENTS_MD_FRAGMENT.start_marker(),
@@ -36,12 +35,14 @@ impl From<UserInstructions> for ResponseItem {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "skill_instructions", rename_all = "snake_case")]
pub struct SkillInstructions {
pub(crate) struct SkillInstructions {
pub name: String,
pub path: String,
pub contents: String,
}
impl SkillInstructions {}
impl From<SkillInstructions> for ResponseItem {
fn from(si: SkillInstructions) -> Self {
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(

View File

@@ -1,11 +1,7 @@
use super::*;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use crate::fragment::AGENTS_MD_FRAGMENT;
use crate::fragment::SKILL_FRAGMENT;
#[test]
fn test_user_instructions() {
let user_instructions = UserInstructions {

View File

@@ -1,2 +1,4 @@
pub use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
pub use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
// Default plaintext sigil for tools.
pub const TOOL_MENTION_SIGIL: char = '$';
// Plugins use `@` in linked plaintext outside TUI.
pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@';

View File

@@ -1,5 +1,3 @@
use super::LoadedPlugin;
use super::PluginLoadOutcome;
use super::PluginManifestPaths;
use super::curated_plugins_repo_path;
use super::load_plugin_manifest;
@@ -22,11 +20,14 @@ use super::remote::fetch_remote_featured_plugin_ids;
use super::remote::fetch_remote_plugin_status;
use super::remote::uninstall_remote_plugin;
use super::startup_sync::start_startup_remote_plugin_sync_once;
use super::store::PluginId;
use super::store::PluginIdError;
use super::store::PluginInstallResult as StorePluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
use super::sync_openai_plugins_repo;
use crate::AuthManager;
use crate::analytics_client::AnalyticsEventsClient;
use crate::auth::CodexAuth;
use crate::config::Config;
use crate::config::ConfigService;
@@ -45,12 +46,6 @@ use crate::skills::loader::load_skills_from_roots;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::MergeStrategy;
use codex_features::Feature;
use codex_plugin::AppConnectorId;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginId;
use codex_plugin::PluginIdError;
use codex_plugin::PluginTelemetryMetadata;
use codex_plugin::prompt_safe_plugin_description;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -73,13 +68,12 @@ use toml_edit::value;
use tracing::info;
use tracing::warn;
use crate::AnalyticsEventsClient;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
std::time::Duration::from_secs(60 * 60 * 3);
@@ -120,6 +114,9 @@ fn featured_plugin_ids_cache_key(
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallRequest {
pub plugin_name: String,
@@ -188,6 +185,89 @@ pub struct ConfiguredMarketplaceListOutcome {
pub errors: Vec<MarketplaceListError>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
pub manifest_name: Option<String>,
pub manifest_description: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
pub disabled_skill_paths: HashSet<PathBuf>,
pub has_enabled_skills: bool,
pub mcp_servers: HashMap<String, McpServerConfig>,
pub apps: Vec<AppConnectorId>,
pub error: Option<String>,
}
impl LoadedPlugin {
fn is_active(&self) -> bool {
self.enabled && self.error.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginCapabilitySummary {
pub config_name: String,
pub display_name: String,
pub description: Option<String>,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<AppConnectorId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginTelemetryMetadata {
pub plugin_id: PluginId,
pub capability_summary: Option<PluginCapabilitySummary>,
}
impl PluginTelemetryMetadata {
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
Self {
plugin_id: plugin_id.clone(),
capability_summary: None,
}
}
}
impl PluginCapabilitySummary {
fn from_plugin(plugin: &LoadedPlugin) -> Option<Self> {
if !plugin.is_active() {
return None;
}
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
mcp_server_names.sort_unstable();
let summary = Self {
config_name: plugin.config_name.clone(),
display_name: plugin
.manifest_name
.clone()
.unwrap_or_else(|| plugin.config_name.clone()),
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
has_skills: plugin.has_enabled_skills,
mcp_server_names,
app_connector_ids: plugin.apps.clone(),
};
(summary.has_skills
|| !summary.mcp_server_names.is_empty()
|| !summary.app_connector_ids.is_empty())
.then_some(summary)
}
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
PluginId::parse(&self.config_name)
.ok()
.map(|plugin_id| PluginTelemetryMetadata {
plugin_id,
capability_summary: Some(self.clone()),
})
}
}
impl From<PluginDetail> for PluginCapabilitySummary {
fn from(value: PluginDetail) -> Self {
let has_skills = value.skills.iter().any(|skill| {
@@ -206,6 +286,95 @@ impl From<PluginDetail> for PluginCapabilitySummary {
}
}
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if description.is_empty() {
return None;
}
Some(
description
.chars()
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
.collect(),
)
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginLoadOutcome {
plugins: Vec<LoadedPlugin>,
capability_summaries: Vec<PluginCapabilitySummary>,
}
impl Default for PluginLoadOutcome {
fn default() -> Self {
Self::from_plugins(Vec::new())
}
}
impl PluginLoadOutcome {
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
let capability_summaries = plugins
.iter()
.filter_map(PluginCapabilitySummary::from_plugin)
.collect::<Vec<_>>();
Self {
plugins,
capability_summaries,
}
}
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
let mut skill_roots: Vec<PathBuf> = self
.plugins
.iter()
.filter(|plugin| plugin.is_active())
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
.collect();
skill_roots.sort_unstable();
skill_roots.dedup();
skill_roots
}
pub fn effective_mcp_servers(&self) -> HashMap<String, McpServerConfig> {
let mut mcp_servers = HashMap::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for (name, config) in &plugin.mcp_servers {
mcp_servers
.entry(name.clone())
.or_insert_with(|| config.clone());
}
}
mcp_servers
}
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
let mut apps = Vec::new();
let mut seen_connector_ids = std::collections::HashSet::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for connector_id in &plugin.apps {
if seen_connector_ids.insert(connector_id.clone()) {
apps.push(connector_id.clone());
}
}
}
apps
}
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
&self.capability_summaries
}
pub fn plugins(&self) -> &[LoadedPlugin] {
&self.plugins
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RemotePluginSyncResult {
/// Plugin ids newly installed into the local plugin cache.
@@ -406,19 +575,6 @@ impl PluginsManager {
*cached_enabled_outcome = None;
}
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
pub fn effective_skill_roots_for_layer_stack(
&self,
config_layer_stack: &ConfigLayerStack,
plugins_feature_enabled: bool,
) -> Vec<PathBuf> {
if !plugins_feature_enabled {
return Vec::new();
}
load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product)
.effective_skill_roots()
}
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
Ok(cache) => cache.clone(),
@@ -1018,7 +1174,7 @@ impl PluginsManager {
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key);
self.start_curated_repo_sync(configured_curated_plugin_ids);
start_startup_remote_plugin_sync_once(
Arc::clone(self),
@@ -1185,7 +1341,7 @@ impl PluginUninstallError {
fn log_plugin_load_errors(outcome: &PluginLoadOutcome) {
for plugin in outcome
.plugins()
.plugins
.iter()
.filter(|plugin| plugin.error.is_some())
{
@@ -1257,6 +1413,16 @@ pub(crate) fn load_plugins_from_layer_stack(
PluginLoadOutcome::from_plugins(plugins)
}
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
for ancestor in path.ancestors() {
if let Some(manifest) = load_plugin_manifest(ancestor) {
return Some(manifest.name);
}
}
None
}
fn refresh_curated_plugin_cache(
codex_home: &Path,
plugin_version: &str,

View File

@@ -7,9 +7,7 @@ use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::LoadedPlugin;
use crate::plugins::MarketplacePluginInstallPolicy;
use crate::plugins::PluginLoadOutcome;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
@@ -28,8 +26,6 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
@@ -134,7 +130,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
assert_eq!(
outcome.plugins(),
outcome.plugins,
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: Some("sample".to_string()),
@@ -224,10 +220,10 @@ enabled = true
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
assert_eq!(
outcome.plugins()[0].disabled_skill_paths,
outcome.plugins[0].disabled_skill_paths,
HashSet::from([skill_path])
);
assert!(!outcome.plugins()[0].has_enabled_skills);
assert!(!outcome.plugins[0].has_enabled_skills);
assert!(outcome.capability_summaries().is_empty());
}
@@ -260,8 +256,8 @@ enabled = true
"#;
let outcome = load_plugins_from_config(config_toml, codex_home.path());
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
assert!(outcome.plugins()[0].has_enabled_skills);
assert!(outcome.plugins[0].disabled_skill_paths.is_empty());
assert!(outcome.plugins[0].has_enabled_skills);
assert_eq!(
outcome.capability_summaries(),
&[PluginCapabilitySummary {
@@ -342,7 +338,7 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
assert_eq!(
outcome.plugins()[0].manifest_description.as_deref(),
outcome.plugins[0].manifest_description.as_deref(),
Some("Plugin that\n includes the sample\tserver")
);
assert_eq!(
@@ -377,7 +373,7 @@ fn capability_summary_truncates_overlong_plugin_descriptions() {
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
assert_eq!(
outcome.plugins()[0].manifest_description.as_deref(),
outcome.plugins[0].manifest_description.as_deref(),
Some(too_long.as_str())
);
assert_eq!(
@@ -457,14 +453,14 @@ fn load_plugins_uses_manifest_configured_component_paths() {
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
assert_eq!(
outcome.plugins()[0].skill_roots,
outcome.plugins[0].skill_roots,
vec![
plugin_root.join("custom-skills"),
plugin_root.join("skills")
]
);
assert_eq!(
outcome.plugins()[0].mcp_servers,
outcome.plugins[0].mcp_servers,
HashMap::from([(
"custom".to_string(),
McpServerConfig {
@@ -487,7 +483,7 @@ fn load_plugins_uses_manifest_configured_component_paths() {
)])
);
assert_eq!(
outcome.plugins()[0].apps,
outcome.plugins[0].apps,
vec![AppConnectorId("connector_custom".to_string())]
);
}
@@ -563,11 +559,11 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
assert_eq!(
outcome.plugins()[0].skill_roots,
outcome.plugins[0].skill_roots,
vec![plugin_root.join("skills")]
);
assert_eq!(
outcome.plugins()[0].mcp_servers,
outcome.plugins[0].mcp_servers,
HashMap::from([(
"default".to_string(),
McpServerConfig {
@@ -590,7 +586,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
)])
);
assert_eq!(
outcome.plugins()[0].apps,
outcome.plugins[0].apps,
vec![AppConnectorId("connector_default".to_string())]
);
}
@@ -622,7 +618,7 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path());
assert_eq!(
outcome.plugins(),
outcome.plugins,
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: None,
@@ -809,6 +805,24 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
);
}
#[test]
fn plugin_namespace_for_skill_path_uses_manifest_name() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(&skill_path, "---\ndescription: search\n---\n");
assert_eq!(
plugin_namespace_for_skill_path(&skill_path),
Some("sample".to_string())
);
}
#[test]
fn load_plugins_returns_empty_when_feature_disabled() {
let codex_home = TempDir::new().unwrap();
@@ -866,9 +880,9 @@ fn load_plugins_rejects_invalid_plugin_keys() {
codex_home.path(),
);
assert_eq!(outcome.plugins().len(), 1);
assert_eq!(outcome.plugins.len(), 1);
assert_eq!(
outcome.plugins()[0].error.as_deref(),
outcome.plugins[0].error.as_deref(),
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
);
assert!(outcome.effective_skill_roots().is_empty());

View File

@@ -1,10 +1,11 @@
use codex_utils_absolute_path::AbsolutePathBuf;
pub(crate) use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Component;
use std::path::Path;
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
const MAX_DEFAULT_PROMPT_LEN: usize = 128;

View File

@@ -1,10 +1,10 @@
use super::PluginManifestInterface;
use super::load_plugin_manifest;
use super::store::PluginId;
use super::store::PluginIdError;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_git_utils::get_git_repo_root;
use codex_plugin::PluginId;
use codex_plugin::PluginIdError;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;

View File

@@ -1,5 +1,3 @@
use crate::config::types::McpServerConfig;
mod discoverable;
mod injection;
mod manager;
@@ -13,36 +11,31 @@ mod store;
pub(crate) mod test_support;
mod toggles;
pub use codex_plugin::AppConnectorId;
pub use codex_plugin::EffectiveSkillRoots;
pub use codex_plugin::PluginCapabilitySummary;
pub use codex_plugin::PluginId;
pub use codex_plugin::PluginIdError;
pub use codex_plugin::PluginTelemetryMetadata;
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
pub(crate) use codex_plugin::plugin_namespace_for_skill_path;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetail;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginLoadOutcome;
pub use manager::PluginReadOutcome;
pub use manager::PluginReadRequest;
pub use manager::PluginRemoteSyncError;
pub use manager::PluginTelemetryMetadata;
pub use manager::PluginUninstallError;
pub use manager::PluginsManager;
pub use manager::RemotePluginSyncResult;
pub use manager::installed_plugin_telemetry_metadata;
pub use manager::load_plugin_apps;
pub use manager::load_plugin_mcp_servers;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub use manager::plugin_telemetry_metadata_from_root;
pub use manifest::PluginManifestInterface;
pub(crate) use manifest::PluginManifestPaths;
@@ -60,4 +53,5 @@ pub(crate) use render::render_plugins_section;
pub(crate) use startup_sync::curated_plugins_repo_path;
pub(crate) use startup_sync::read_curated_plugins_sha;
pub(crate) use startup_sync::sync_openai_plugins_repo;
pub use store::PluginId;
pub use toggles::collect_plugin_enabled_candidates;

View File

@@ -1,8 +1,6 @@
use super::load_plugin_manifest;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use super::manifest::PLUGIN_MANIFEST_PATH;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
use std::fs;
use std::io;
use std::path::Path;
@@ -11,6 +9,53 @@ use std::path::PathBuf;
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
#[derive(Debug, thiserror::Error)]
pub enum PluginIdError {
#[error("{0}")]
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginId {
pub plugin_name: String,
pub marketplace_name: String,
}
impl PluginId {
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(PluginIdError::Invalid)?;
Ok(Self {
plugin_name,
marketplace_name,
})
}
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
};
if plugin_name.is_empty() || marketplace_name.is_empty() {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
}
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
PluginIdError::Invalid(message) => {
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
}
})
}
pub fn as_key(&self) -> String {
format!("{}@{}", self.plugin_name, self.marketplace_name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallResult {
pub plugin_id: PluginId,
@@ -176,6 +221,21 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
.map(|_| plugin_name)
}
fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
if segment.is_empty() {
return Err(format!("invalid {kind}: must not be empty"));
}
if !segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
return Err(format!(
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
));
}
Ok(())
}
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
if !path.exists() {
return Ok(());

View File

@@ -1,5 +1,4 @@
use super::*;
use codex_plugin::PluginId;
use pretty_assertions::assert_eq;
use tempfile::tempdir;

View File

@@ -6,8 +6,10 @@ expression: snapshot
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
## First Forked Turn Request
00:message/developer[2]:
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:fork seed
03:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:after fork
04:message/user:after fork

View File

@@ -1,176 +0,0 @@
use std::path::Path;
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use tokio::process::Command;
use tokio::time::timeout;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct GrepFilesHandler;
const DEFAULT_LIMIT: usize = 100;
const MAX_LIMIT: usize = 2000;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
fn default_limit() -> usize {
DEFAULT_LIMIT
}
#[derive(Deserialize)]
struct GrepFilesArgs {
pattern: String,
#[serde(default)]
include: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
}
#[async_trait]
impl ToolHandler for GrepFilesHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation { payload, turn, .. } = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"grep_files handler received unsupported payload".to_string(),
));
}
};
let args: GrepFilesArgs = parse_arguments(&arguments)?;
let pattern = args.pattern.trim();
if pattern.is_empty() {
return Err(FunctionCallError::RespondToModel(
"pattern must not be empty".to_string(),
));
}
if args.limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
let limit = args.limit.min(MAX_LIMIT);
let search_path = turn.resolve_path(args.path.clone());
verify_path_exists(&search_path).await?;
let include = args.include.as_deref().map(str::trim).and_then(|val| {
if val.is_empty() {
None
} else {
Some(val.to_string())
}
});
let search_results =
run_rg_search(pattern, include.as_deref(), &search_path, limit, &turn.cwd).await?;
if search_results.is_empty() {
Ok(FunctionToolOutput::from_text(
"No matches found.".to_string(),
Some(false),
))
} else {
Ok(FunctionToolOutput::from_text(
search_results.join("\n"),
Some(true),
))
}
}
}
async fn verify_path_exists(path: &Path) -> Result<(), FunctionCallError> {
tokio::fs::metadata(path).await.map_err(|err| {
FunctionCallError::RespondToModel(format!("unable to access `{}`: {err}", path.display()))
})?;
Ok(())
}
async fn run_rg_search(
pattern: &str,
include: Option<&str>,
search_path: &Path,
limit: usize,
cwd: &Path,
) -> Result<Vec<String>, FunctionCallError> {
let mut command = Command::new("rg");
command
.current_dir(cwd)
.arg("--files-with-matches")
.arg("--sortr=modified")
.arg("--regexp")
.arg(pattern)
.arg("--no-messages");
if let Some(glob) = include {
command.arg("--glob").arg(glob);
}
command.arg("--").arg(search_path);
let output = timeout(COMMAND_TIMEOUT, command.output())
.await
.map_err(|_| {
FunctionCallError::RespondToModel("rg timed out after 30 seconds".to_string())
})?
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to launch rg: {err}. Ensure ripgrep is installed and on PATH."
))
})?;
match output.status.code() {
Some(0) => Ok(parse_results(&output.stdout, limit)),
Some(1) => Ok(Vec::new()),
_ => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(FunctionCallError::RespondToModel(format!(
"rg failed: {stderr}"
)))
}
}
}
fn parse_results(stdout: &[u8], limit: usize) -> Vec<String> {
let mut results = Vec::new();
for line in stdout.split(|byte| *byte == b'\n') {
if line.is_empty() {
continue;
}
if let Ok(text) = std::str::from_utf8(line) {
if text.is_empty() {
continue;
}
results.push(text.to_string());
if results.len() == limit {
break;
}
}
}
results
}
#[cfg(test)]
#[path = "grep_files_tests.rs"]
mod tests;

View File

@@ -1,271 +0,0 @@
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fs::FileType;
use std::path::Path;
use std::path::PathBuf;
use async_trait::async_trait;
use codex_utils_string::take_bytes_at_char_boundary;
use serde::Deserialize;
use tokio::fs;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct ListDirHandler;
const MAX_ENTRY_LENGTH: usize = 500;
const INDENTATION_SPACES: usize = 2;
fn default_offset() -> usize {
1
}
fn default_limit() -> usize {
25
}
fn default_depth() -> usize {
2
}
#[derive(Deserialize)]
struct ListDirArgs {
dir_path: String,
#[serde(default = "default_offset")]
offset: usize,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default = "default_depth")]
depth: usize,
}
#[async_trait]
impl ToolHandler for ListDirHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation { payload, .. } = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"list_dir handler received unsupported payload".to_string(),
));
}
};
let args: ListDirArgs = parse_arguments(&arguments)?;
let ListDirArgs {
dir_path,
offset,
limit,
depth,
} = args;
if offset == 0 {
return Err(FunctionCallError::RespondToModel(
"offset must be a 1-indexed entry number".to_string(),
));
}
if limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
if depth == 0 {
return Err(FunctionCallError::RespondToModel(
"depth must be greater than zero".to_string(),
));
}
let path = PathBuf::from(&dir_path);
if !path.is_absolute() {
return Err(FunctionCallError::RespondToModel(
"dir_path must be an absolute path".to_string(),
));
}
let entries = list_dir_slice(&path, offset, limit, depth).await?;
let mut output = Vec::with_capacity(entries.len() + 1);
output.push(format!("Absolute path: {}", path.display()));
output.extend(entries);
Ok(FunctionToolOutput::from_text(output.join("\n"), Some(true)))
}
}
async fn list_dir_slice(
path: &Path,
offset: usize,
limit: usize,
depth: usize,
) -> Result<Vec<String>, FunctionCallError> {
let mut entries = Vec::new();
collect_entries(path, Path::new(""), depth, &mut entries).await?;
if entries.is_empty() {
return Ok(Vec::new());
}
entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
let start_index = offset - 1;
if start_index >= entries.len() {
return Err(FunctionCallError::RespondToModel(
"offset exceeds directory entry count".to_string(),
));
}
let remaining_entries = entries.len() - start_index;
let capped_limit = limit.min(remaining_entries);
let end_index = start_index + capped_limit;
let selected_entries = &entries[start_index..end_index];
let mut formatted = Vec::with_capacity(selected_entries.len());
for entry in selected_entries {
formatted.push(format_entry_line(entry));
}
if end_index < entries.len() {
formatted.push(format!("More than {capped_limit} entries found"));
}
Ok(formatted)
}
async fn collect_entries(
dir_path: &Path,
relative_prefix: &Path,
depth: usize,
entries: &mut Vec<DirEntry>,
) -> Result<(), FunctionCallError> {
let mut queue = VecDeque::new();
queue.push_back((dir_path.to_path_buf(), relative_prefix.to_path_buf(), depth));
while let Some((current_dir, prefix, remaining_depth)) = queue.pop_front() {
let mut read_dir = fs::read_dir(&current_dir).await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to read directory: {err}"))
})?;
let mut dir_entries = Vec::new();
while let Some(entry) = read_dir.next_entry().await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to read directory: {err}"))
})? {
let file_type = entry.file_type().await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to inspect entry: {err}"))
})?;
let file_name = entry.file_name();
let relative_path = if prefix.as_os_str().is_empty() {
PathBuf::from(&file_name)
} else {
prefix.join(&file_name)
};
let display_name = format_entry_component(&file_name);
let display_depth = prefix.components().count();
let sort_key = format_entry_name(&relative_path);
let kind = DirEntryKind::from(&file_type);
dir_entries.push((
entry.path(),
relative_path,
kind,
DirEntry {
name: sort_key,
display_name,
depth: display_depth,
kind,
},
));
}
dir_entries.sort_unstable_by(|a, b| a.3.name.cmp(&b.3.name));
for (entry_path, relative_path, kind, dir_entry) in dir_entries {
if kind == DirEntryKind::Directory && remaining_depth > 1 {
queue.push_back((entry_path, relative_path, remaining_depth - 1));
}
entries.push(dir_entry);
}
}
Ok(())
}
fn format_entry_name(path: &Path) -> String {
let normalized = path.to_string_lossy().replace("\\", "/");
if normalized.len() > MAX_ENTRY_LENGTH {
take_bytes_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string()
} else {
normalized
}
}
fn format_entry_component(name: &OsStr) -> String {
let normalized = name.to_string_lossy();
if normalized.len() > MAX_ENTRY_LENGTH {
take_bytes_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string()
} else {
normalized.to_string()
}
}
fn format_entry_line(entry: &DirEntry) -> String {
let indent = " ".repeat(entry.depth * INDENTATION_SPACES);
let mut name = entry.display_name.clone();
match entry.kind {
DirEntryKind::Directory => name.push('/'),
DirEntryKind::Symlink => name.push('@'),
DirEntryKind::Other => name.push('?'),
DirEntryKind::File => {}
}
format!("{indent}{name}")
}
#[derive(Clone)]
struct DirEntry {
name: String,
display_name: String,
depth: usize,
kind: DirEntryKind,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DirEntryKind {
Directory,
File,
Symlink,
Other,
}
impl From<&FileType> for DirEntryKind {
fn from(file_type: &FileType) -> Self {
if file_type.is_symlink() {
DirEntryKind::Symlink
} else if file_type.is_dir() {
DirEntryKind::Directory
} else if file_type.is_file() {
DirEntryKind::File
} else {
DirEntryKind::Other
}
}
}
#[cfg(test)]
#[path = "list_dir_tests.rs"]
mod tests;

View File

@@ -2,9 +2,7 @@ pub(crate) mod agent_jobs;
pub mod apply_patch;
mod artifacts;
mod dynamic;
mod grep_files;
mod js_repl;
mod list_dir;
mod mcp;
mod mcp_resource;
pub(crate) mod multi_agents;
@@ -41,10 +39,8 @@ pub use artifacts::ArtifactsHandler;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use dynamic::DynamicToolHandler;
pub use grep_files::GrepFilesHandler;
pub use js_repl::JsReplHandler;
pub use js_repl::JsReplResetHandler;
pub use list_dir::ListDirHandler;
pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;

View File

@@ -1839,59 +1839,6 @@ fn create_test_sync_tool() -> ToolSpec {
})
}
fn create_grep_files_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"pattern".to_string(),
JsonSchema::String {
description: Some("Regular expression pattern to search for.".to_string()),
},
),
(
"include".to_string(),
JsonSchema::String {
description: Some(
"Optional glob that limits which files are searched (e.g. \"*.rs\" or \
\"*.{ts,tsx}\")."
.to_string(),
),
},
),
(
"path".to_string(),
JsonSchema::String {
description: Some(
"Directory or file path to search. Defaults to the session's working directory."
.to_string(),
),
},
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of file paths to return (defaults to 100).".to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "grep_files".to_string(),
description: "Finds files whose contents match the pattern and lists them by modification \
time."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["pattern".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
let properties = BTreeMap::from([
(
@@ -2204,54 +2151,6 @@ fn create_read_file_tool() -> ToolSpec {
})
}
fn create_list_dir_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"dir_path".to_string(),
JsonSchema::String {
description: Some("Absolute path to the directory to list.".to_string()),
},
),
(
"offset".to_string(),
JsonSchema::Number {
description: Some(
"The entry number to start listing from. Must be 1 or greater.".to_string(),
),
},
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some("The maximum number of entries to return.".to_string()),
},
),
(
"depth".to_string(),
JsonSchema::Number {
description: Some(
"The maximum directory depth to traverse. Must be 1 or greater.".to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "list_dir".to_string(),
description:
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["dir_path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
fn create_js_repl_tool() -> ToolSpec {
// Keep JS input freeform, but block the most common malformed payload shapes
// (JSON wrappers, quoted strings, and markdown fences) before they reach the
@@ -2751,10 +2650,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::CodeModeExecuteHandler;
use crate::tools::handlers::CodeModeWaitHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::JsReplHandler;
use crate::tools::handlers::JsReplResetHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
@@ -3029,20 +2926,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler("apply_patch", apply_patch_handler);
}
if config
.experimental_supported_tools
.contains(&"grep_files".to_string())
{
let grep_files_handler = Arc::new(GrepFilesHandler);
push_tool_spec(
&mut builder,
create_grep_files_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
builder.register_handler("grep_files", grep_files_handler);
}
if config
.experimental_supported_tools
.contains(&"read_file".to_string())
@@ -3057,21 +2940,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler("read_file", read_file_handler);
}
if config
.experimental_supported_tools
.iter()
.any(|tool| tool == "list_dir")
{
let list_dir_handler = Arc::new(ListDirHandler);
push_tool_spec(
&mut builder,
create_list_dir_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
builder.register_handler("list_dir", list_dir_handler);
}
if config
.experimental_supported_tools
.contains(&"test_sync_tool".to_string())

View File

@@ -1665,21 +1665,13 @@ fn test_parallel_support_flags() {
assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls);
assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls);
assert!(find_tool(&tools, "grep_files").supports_parallel_tool_calls);
assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls);
assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls);
}
#[test]
fn test_test_model_info_includes_sync_tool() {
let _config = test_config();
let mut model_info = model_info_from_models_json("gpt-5-codex");
model_info.experimental_supported_tools = vec![
"test_sync_tool".to_string(),
"read_file".to_string(),
"grep_files".to_string(),
"list_dir".to_string(),
];
model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()];
let features = Features::with_defaults();
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
@@ -1698,17 +1690,6 @@ fn test_test_model_info_includes_sync_tool() {
.iter()
.any(|tool| tool_name(&tool.spec) == "test_sync_tool")
);
assert!(
tools
.iter()
.any(|tool| tool_name(&tool.spec) == "read_file")
);
assert!(
tools
.iter()
.any(|tool| tool_name(&tool.spec) == "grep_files")
);
assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir"));
}
#[test]

View File

@@ -616,12 +616,7 @@ fn ensure_test_model_catalog(config: &mut Config) -> Result<()> {
.unwrap_or_else(|| panic!("missing bundled model gpt-5.1-codex"));
model.slug = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string();
model.display_name = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string();
model.experimental_supported_tools = vec![
"test_sync_tool".to_string(),
"read_file".to_string(),
"grep_files".to_string(),
"list_dir".to_string(),
];
model.experimental_supported_tools = vec!["test_sync_tool".to_string()];
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});

View File

@@ -1,145 +0,0 @@
#![cfg(not(target_os = "windows"))]
use anyhow::Result;
use core_test_support::responses::mount_function_call_agent_response;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use std::collections::HashSet;
use std::path::Path;
use std::process::Command as StdCommand;
const MODEL_WITH_TOOL: &str = "test-gpt-5.1-codex";
fn ripgrep_available() -> bool {
StdCommand::new("rg")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
macro_rules! skip_if_ripgrep_missing {
($ret:expr $(,)?) => {{
if !ripgrep_available() {
eprintln!("rg not available in PATH; skipping test");
return $ret;
}
}};
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn grep_files_tool_collects_matches() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_ripgrep_missing!(Ok(()));
let server = start_mock_server().await;
let test = build_test_codex(&server).await?;
let search_dir = test.cwd.path().join("src");
std::fs::create_dir_all(&search_dir)?;
let alpha = search_dir.join("alpha.rs");
let beta = search_dir.join("beta.rs");
let gamma = search_dir.join("gamma.txt");
std::fs::write(&alpha, "alpha needle\n")?;
std::fs::write(&beta, "beta needle\n")?;
std::fs::write(&gamma, "needle in text but excluded\n")?;
let call_id = "grep-files-collect";
let arguments = serde_json::json!({
"pattern": "needle",
"path": search_dir.to_string_lossy(),
"include": "*.rs",
})
.to_string();
let mocks =
mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await;
test.submit_turn("please find uses of needle").await?;
let req = mocks.completion.single_request();
let (content_opt, success_opt) = req
.function_call_output_content_and_success(call_id)
.expect("tool output present");
let content = content_opt.expect("content present");
let success = success_opt.unwrap_or(true);
assert!(
success,
"expected success for matches, got content={content}"
);
let entries = collect_file_names(&content);
assert_eq!(entries.len(), 2, "content: {content}");
assert!(
entries.contains("alpha.rs"),
"missing alpha.rs in {entries:?}"
);
assert!(
entries.contains("beta.rs"),
"missing beta.rs in {entries:?}"
);
assert!(
!entries.contains("gamma.txt"),
"txt file should be filtered out: {entries:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn grep_files_tool_reports_empty_results() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_ripgrep_missing!(Ok(()));
let server = start_mock_server().await;
let test = build_test_codex(&server).await?;
let search_dir = test.cwd.path().join("logs");
std::fs::create_dir_all(&search_dir)?;
std::fs::write(search_dir.join("output.txt"), "no hits here")?;
let call_id = "grep-files-empty";
let arguments = serde_json::json!({
"pattern": "needle",
"path": search_dir.to_string_lossy(),
"limit": 5,
})
.to_string();
let mocks =
mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await;
test.submit_turn("search again").await?;
let req = mocks.completion.single_request();
let (content_opt, success_opt) = req
.function_call_output_content_and_success(call_id)
.expect("tool output present");
let content = content_opt.expect("content present");
if let Some(success) = success_opt {
assert!(!success, "expected success=false content={content}");
}
assert_eq!(content, "No matches found.");
Ok(())
}
#[allow(clippy::expect_used)]
async fn build_test_codex(server: &wiremock::MockServer) -> Result<TestCodex> {
let mut builder = test_codex().with_model(MODEL_WITH_TOOL);
builder.build(server).await
}
fn collect_file_names(content: &str) -> HashSet<String> {
content
.lines()
.filter_map(|line| {
if line.trim().is_empty() {
return None;
}
Path::new(line)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.collect()
}

View File

@@ -1,167 +0,0 @@
#![cfg(not(target_os = "windows"))]
use core_test_support::responses::mount_function_call_agent_response;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "disabled until we enable list_dir tool"]
async fn list_dir_tool_returns_entries() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let dir_path = test.cwd.path().join("sample_dir");
std::fs::create_dir(&dir_path)?;
std::fs::write(dir_path.join("alpha.txt"), "first file")?;
std::fs::create_dir(dir_path.join("nested"))?;
let dir_path = dir_path.to_string_lossy().to_string();
let call_id = "list-dir-call";
let arguments = json!({
"dir_path": dir_path,
"offset": 1,
"limit": 2,
})
.to_string();
let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await;
test.submit_turn("list directory contents").await?;
let req = mocks.completion.single_request();
let (content_opt, _) = req
.function_call_output_content_and_success(call_id)
.expect("function_call_output present");
let output = content_opt.expect("output content present in tool output");
assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "disabled until we enable list_dir tool"]
async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let dir_path = test.cwd.path().join("depth_one");
std::fs::create_dir(&dir_path)?;
std::fs::write(dir_path.join("alpha.txt"), "alpha")?;
std::fs::create_dir(dir_path.join("nested"))?;
std::fs::write(dir_path.join("nested").join("beta.txt"), "beta")?;
let dir_path = dir_path.to_string_lossy().to_string();
let call_id = "list-dir-depth1";
let arguments = json!({
"dir_path": dir_path,
"offset": 1,
"limit": 10,
"depth": 1,
})
.to_string();
let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await;
test.submit_turn("list directory contents depth one")
.await?;
let req = mocks.completion.single_request();
let (content_opt, _) = req
.function_call_output_content_and_success(call_id)
.expect("function_call_output present");
let output = content_opt.expect("output content present in tool output");
assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "disabled until we enable list_dir tool"]
async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let dir_path = test.cwd.path().join("depth_two");
std::fs::create_dir(&dir_path)?;
std::fs::write(dir_path.join("alpha.txt"), "alpha")?;
let nested = dir_path.join("nested");
std::fs::create_dir(&nested)?;
std::fs::write(nested.join("beta.txt"), "beta")?;
let deeper = nested.join("grand");
std::fs::create_dir(&deeper)?;
std::fs::write(deeper.join("gamma.txt"), "gamma")?;
let dir_path_string = dir_path.to_string_lossy().to_string();
let call_id = "list-dir-depth2";
let arguments = json!({
"dir_path": dir_path_string,
"offset": 1,
"limit": 10,
"depth": 2,
})
.to_string();
let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await;
test.submit_turn("list directory contents depth two")
.await?;
let req = mocks.completion.single_request();
let (content_opt, _) = req
.function_call_output_content_and_success(call_id)
.expect("function_call_output present");
let output = content_opt.expect("output content present in tool output");
assert_eq!(
output,
"E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "disabled until we enable list_dir tool"]
async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let dir_path = test.cwd.path().join("depth_three");
std::fs::create_dir(&dir_path)?;
std::fs::write(dir_path.join("alpha.txt"), "alpha")?;
let nested = dir_path.join("nested");
std::fs::create_dir(&nested)?;
std::fs::write(nested.join("beta.txt"), "beta")?;
let deeper = nested.join("grand");
std::fs::create_dir(&deeper)?;
std::fs::write(deeper.join("gamma.txt"), "gamma")?;
let dir_path_string = dir_path.to_string_lossy().to_string();
let call_id = "list-dir-depth3";
let arguments = json!({
"dir_path": dir_path_string,
"offset": 1,
"limit": 10,
"depth": 3,
})
.to_string();
let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await;
test.submit_turn("list directory contents depth three")
.await?;
let req = mocks.completion.single_request();
let (content_opt, _) = req
.function_call_output_content_and_success(call_id)
.expect("function_call_output present");
let output = content_opt.expect("output content present in tool output");
assert_eq!(
output,
"E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand\nE5: [file] nested/grand/gamma.txt"
);
Ok(())
}

View File

@@ -75,7 +75,6 @@ mod deprecation_notice;
mod exec;
mod exec_policy;
mod fork_thread;
mod grep_files;
mod hierarchical_agents;
#[cfg(not(target_os = "windows"))]
mod hooks;
@@ -83,7 +82,6 @@ mod image_rollout;
mod items;
mod js_repl;
mod json_result;
mod list_dir;
mod live_cli;
mod live_reload;
mod memories;
@@ -101,7 +99,6 @@ mod personality_migration;
mod plugins;
mod prompt_caching;
mod quota_exceeded;
mod read_file;
mod realtime_conversation;
mod remote_env;
mod remote_models;

View File

@@ -1,42 +0,0 @@
#![cfg(not(target_os = "windows"))]
use core_test_support::responses::mount_function_call_agent_response;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "disabled until we enable read_file tool"]
async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let file_path = test.cwd.path().join("sample.txt");
std::fs::write(&file_path, "first\nsecond\nthird\nfourth\n")?;
let file_path = file_path.to_string_lossy().to_string();
let call_id = "read-file-call";
let arguments = json!({
"file_path": file_path,
"offset": 2,
"limit": 2,
})
.to_string();
let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "read_file").await;
test.submit_turn("please inspect sample.txt").await?;
let req = mocks.completion.single_request();
let (output_text_opt, _) = req
.function_call_output_content_and_success(call_id)
.expect("output present");
let output_text = output_text_opt.expect("output text present");
assert_eq!(output_text, "L2: second\nL3: third");
Ok(())
}

View File

@@ -1,16 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "instructions",
crate_name = "codex_instructions",
compile_data = glob(
include = ["**"],
exclude = [
"BUILD.bazel",
"Cargo.toml",
],
allow_empty = True,
) + [
"//codex-rs:node-version.txt",
],
)

View File

@@ -1,20 +0,0 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-instructions"
version.workspace = true
[lib]
doctest = false
name = "codex_instructions"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -1,61 +0,0 @@
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
pub const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
pub const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
pub const SKILL_OPEN_TAG: &str = "<skill>";
pub const SKILL_CLOSE_TAG: &str = "</skill>";
#[derive(Clone, Copy)]
pub struct ContextualUserFragmentDefinition {
start_marker: &'static str,
end_marker: &'static str,
}
impl ContextualUserFragmentDefinition {
pub const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
Self {
start_marker,
end_marker,
}
}
pub fn matches_text(&self, text: &str) -> bool {
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..self.start_marker.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
starts_with_marker && ends_with_marker
}
pub const fn start_marker(&self) -> &'static str {
self.start_marker
}
pub const fn end_marker(&self) -> &'static str {
self.end_marker
}
pub fn wrap(&self, body: String) -> String {
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
}
pub fn into_message(self, text: String) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
end_turn: None,
phase: None,
}
}
}
pub const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
pub const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);

View File

@@ -1,15 +0,0 @@
//! User and skill instruction payloads and contextual user fragment markers for Codex prompts.
mod fragment;
mod user_instructions;
pub use fragment::AGENTS_MD_END_MARKER;
pub use fragment::AGENTS_MD_FRAGMENT;
pub use fragment::AGENTS_MD_START_MARKER;
pub use fragment::ContextualUserFragmentDefinition;
pub use fragment::SKILL_CLOSE_TAG;
pub use fragment::SKILL_FRAGMENT;
pub use fragment::SKILL_OPEN_TAG;
pub use user_instructions::SkillInstructions;
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
pub use user_instructions::UserInstructions;

View File

@@ -1,15 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "plugin",
crate_name = "codex_plugin",
compile_data = glob(
include = ["**"],
exclude = [
"**/* *",
"BUILD.bazel",
"Cargo.toml",
],
allow_empty = True,
),
)

View File

@@ -1,18 +0,0 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-plugin"
version.workspace = true
[lib]
doctest = false
name = "codex_plugin"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-utils-absolute-path = { workspace = true }
codex-utils-plugins = { workspace = true }
thiserror = { workspace = true }

View File

@@ -1,55 +0,0 @@
//! Shared plugin identifiers and telemetry-facing summaries.
pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
pub use codex_utils_plugins::mention_syntax;
pub use codex_utils_plugins::plugin_namespace_for_skill_path;
mod load_outcome;
mod plugin_id;
pub use load_outcome::EffectiveSkillRoots;
pub use load_outcome::LoadedPlugin;
pub use load_outcome::PluginLoadOutcome;
pub use load_outcome::prompt_safe_plugin_description;
pub use plugin_id::PluginId;
pub use plugin_id::PluginIdError;
pub use plugin_id::validate_plugin_segment;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginCapabilitySummary {
pub config_name: String,
pub display_name: String,
pub description: Option<String>,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<AppConnectorId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginTelemetryMetadata {
pub plugin_id: PluginId,
pub capability_summary: Option<PluginCapabilitySummary>,
}
impl PluginTelemetryMetadata {
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
Self {
plugin_id: plugin_id.clone(),
capability_summary: None,
}
}
}
impl PluginCapabilitySummary {
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
PluginId::parse(&self.config_name)
.ok()
.map(|plugin_id| PluginTelemetryMetadata {
plugin_id,
capability_summary: Some(self.clone()),
})
}
}

View File

@@ -1,163 +0,0 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::AppConnectorId;
use crate::PluginCapabilitySummary;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
/// A plugin that was loaded from disk, including merged MCP server definitions.
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin<M> {
pub config_name: String,
pub manifest_name: Option<String>,
pub manifest_description: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
pub disabled_skill_paths: HashSet<PathBuf>,
pub has_enabled_skills: bool,
pub mcp_servers: HashMap<String, M>,
pub apps: Vec<AppConnectorId>,
pub error: Option<String>,
}
impl<M> LoadedPlugin<M> {
pub fn is_active(&self) -> bool {
self.enabled && self.error.is_none()
}
}
fn plugin_capability_summary_from_loaded<M>(
plugin: &LoadedPlugin<M>,
) -> Option<PluginCapabilitySummary> {
if !plugin.is_active() {
return None;
}
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
mcp_server_names.sort_unstable();
let summary = PluginCapabilitySummary {
config_name: plugin.config_name.clone(),
display_name: plugin
.manifest_name
.clone()
.unwrap_or_else(|| plugin.config_name.clone()),
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
has_skills: plugin.has_enabled_skills,
mcp_server_names,
app_connector_ids: plugin.apps.clone(),
};
(summary.has_skills
|| !summary.mcp_server_names.is_empty()
|| !summary.app_connector_ids.is_empty())
.then_some(summary)
}
/// Normalizes plugin descriptions for inclusion in model-facing capability summaries.
pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if description.is_empty() {
return None;
}
Some(
description
.chars()
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
.collect(),
)
}
/// Outcome of loading configured plugins (skills roots, MCP, apps, errors).
#[derive(Debug, Clone, PartialEq)]
pub struct PluginLoadOutcome<M> {
plugins: Vec<LoadedPlugin<M>>,
capability_summaries: Vec<PluginCapabilitySummary>,
}
impl<M: Clone> Default for PluginLoadOutcome<M> {
fn default() -> Self {
Self::from_plugins(Vec::new())
}
}
impl<M: Clone> PluginLoadOutcome<M> {
pub fn from_plugins(plugins: Vec<LoadedPlugin<M>>) -> Self {
let capability_summaries = plugins
.iter()
.filter_map(plugin_capability_summary_from_loaded)
.collect::<Vec<_>>();
Self {
plugins,
capability_summaries,
}
}
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
let mut skill_roots: Vec<PathBuf> = self
.plugins
.iter()
.filter(|plugin| plugin.is_active())
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
.collect();
skill_roots.sort_unstable();
skill_roots.dedup();
skill_roots
}
pub fn effective_mcp_servers(&self) -> HashMap<String, M> {
let mut mcp_servers = HashMap::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for (name, config) in &plugin.mcp_servers {
mcp_servers
.entry(name.clone())
.or_insert_with(|| config.clone());
}
}
mcp_servers
}
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
let mut apps = Vec::new();
let mut seen_connector_ids = HashSet::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for connector_id in &plugin.apps {
if seen_connector_ids.insert(connector_id.clone()) {
apps.push(connector_id.clone());
}
}
}
apps
}
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
&self.capability_summaries
}
pub fn plugins(&self) -> &[LoadedPlugin<M>] {
&self.plugins
}
}
/// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin`
/// without naming the MCP config type parameter.
pub trait EffectiveSkillRoots {
fn effective_skill_roots(&self) -> Vec<PathBuf>;
}
impl<M: Clone> EffectiveSkillRoots for PluginLoadOutcome<M> {
fn effective_skill_roots(&self) -> Vec<PathBuf> {
PluginLoadOutcome::effective_skill_roots(self)
}
}

View File

@@ -1,64 +0,0 @@
//! Stable plugin identifier parsing and validation shared with the plugin cache.
#[derive(Debug, thiserror::Error)]
pub enum PluginIdError {
#[error("{0}")]
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginId {
pub plugin_name: String,
pub marketplace_name: String,
}
impl PluginId {
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(PluginIdError::Invalid)?;
Ok(Self {
plugin_name,
marketplace_name,
})
}
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
};
if plugin_name.is_empty() || marketplace_name.is_empty() {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
}
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
PluginIdError::Invalid(message) => {
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
}
})
}
pub fn as_key(&self) -> String {
format!("{}@{}", self.plugin_name, self.marketplace_name)
}
}
/// Validates a single path segment used in plugin IDs and cache layout.
pub fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
if segment.is_empty() {
return Err(format!("invalid {kind}: must not be empty"));
}
if !segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
return Err(format!(
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
));
}
Ok(())
}

View File

@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "plugins",
crate_name = "codex_utils_plugins",
)

View File

@@ -1,21 +0,0 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-utils-plugins"
version.workspace = true
[lib]
doctest = false
name = "codex_utils_plugins"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -1,7 +0,0 @@
//! Plugin path resolution and plaintext mention sigils shared across Codex crates.
pub mod mention_syntax;
pub mod plugin_namespace;
pub use plugin_namespace::PLUGIN_MANIFEST_PATH;
pub use plugin_namespace::plugin_namespace_for_skill_path;

View File

@@ -1,7 +0,0 @@
//! Sigils for tool/plugin mentions in plaintext (shared across Codex crates).
/// Default plaintext sigil for tools.
pub const TOOL_MENTION_SIGIL: char = '$';
/// Plugins use `@` in linked plaintext outside TUI.
pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@';

View File

@@ -1,70 +0,0 @@
//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
use std::fs;
use std::path::Path;
/// Relative path from a plugin root to its manifest file.
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifestName {
#[serde(default)]
name: String,
}
fn plugin_manifest_name(plugin_root: &Path) -> Option<String> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return None;
}
let contents = fs::read_to_string(&manifest_path).ok()?;
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
Some(
plugin_root
.file_name()
.and_then(|entry| entry.to_str())
.filter(|_| raw_name.trim().is_empty())
.unwrap_or(raw_name.as_str())
.to_string(),
)
}
/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
/// plugin manifest (same `name` rules as full manifest loading in codex-core).
pub fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
for ancestor in path.ancestors() {
if let Some(name) = plugin_manifest_name(ancestor) {
return Some(name);
}
}
None
}
#[cfg(test)]
mod tests {
use super::plugin_namespace_for_skill_path;
use std::fs;
use tempfile::tempdir;
#[test]
fn uses_manifest_name() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write manifest");
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
assert_eq!(
plugin_namespace_for_skill_path(&skill_path),
Some("sample".to_string())
);
}
}