Files
codex/codex-rs/app-server/src/config/external_agent_config.rs
pakrym-oai 833212115e Move external agent config out of core (#18850)
## Summary
- Move external agent config migration logic and tests from `codex-core`
into `app-server/src/config`.
- Keep the migration service crate-private to app-server and update the
API adapter imports.
- Remove stale core re-exports and expose only the needed marketplace
source helper.

## Testing
- `cargo test -p codex-app-server config::external_agent_config`
- `just fmt`
- `just fix -p codex-app-server`
- `just fix -p codex-core`
- `git diff --check`
2026-04-21 08:33:58 -07:00

1186 lines
41 KiB
Rust

use codex_config::types::PluginConfig;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::plugins::MarketplaceAddRequest;
use codex_core::plugins::PluginId;
use codex_core::plugins::PluginInstallRequest;
use codex_core::plugins::PluginsManager;
use codex_core::plugins::add_marketplace;
use codex_core::plugins::is_local_marketplace_source;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_protocol::protocol::Product;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
const EXTERNAL_AGENT_DIR: &str = ".claude";
const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";
const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExternalAgentConfigDetectOptions {
pub include_home: bool,
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExternalAgentConfigMigrationItemType {
Config,
Skills,
AgentsMd,
Plugins,
McpServerConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PluginsMigration {
pub marketplace_name: String,
pub plugin_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PendingPluginImport {
pub cwd: Option<PathBuf>,
pub details: MigrationDetails,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PluginImportOutcome {
pub succeeded_marketplaces: Vec<String>,
pub succeeded_plugin_ids: Vec<String>,
pub failed_marketplaces: Vec<String>,
pub failed_plugin_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
pub cwd: Option<PathBuf>,
pub details: Option<MigrationDetails>,
}
#[derive(Clone)]
pub(crate) struct ExternalAgentConfigService {
codex_home: PathBuf,
external_agent_home: PathBuf,
}
impl ExternalAgentConfigService {
pub(crate) fn new(codex_home: PathBuf) -> Self {
let external_agent_home = default_external_agent_home();
Self {
codex_home,
external_agent_home,
}
}
#[cfg(test)]
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
Self {
codex_home,
external_agent_home,
}
}
pub(crate) async fn detect(
&self,
params: ExternalAgentConfigDetectOptions,
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
let mut items = Vec::new();
if params.include_home {
self.detect_migrations(/*repo_root*/ None, &mut items)
.await?;
}
for cwd in params.cwds.as_deref().unwrap_or(&[]) {
let Some(repo_root) = find_repo_root(Some(cwd))? else {
continue;
};
self.detect_migrations(Some(&repo_root), &mut items).await?;
}
Ok(items)
}
pub(crate) async fn import(
&self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<Vec<PendingPluginImport>> {
let mut pending_plugin_imports = Vec::new();
for migration_item in migration_items {
match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
self.import_config(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Config,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Skills => {
let skills_count = self.import_skills(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Skills,
Some(skills_count),
);
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
self.import_agents_md(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::AgentsMd,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Plugins => {
let cwd = migration_item.cwd;
let details = migration_item.details.ok_or_else(|| {
invalid_data_error("plugins migration item is missing details".to_string())
})?;
let (local_details, remote_details) =
self.partition_plugin_migration_details(cwd.as_deref(), details)?;
if let Some(local_details) = local_details {
self.import_plugins(cwd.as_deref(), Some(local_details))
.await?;
}
if let Some(remote_details) = remote_details {
pending_plugin_imports.push(PendingPluginImport {
cwd,
details: remote_details,
});
}
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {}
}
}
Ok(pending_plugin_imports)
}
async fn detect_migrations(
&self,
repo_root: Option<&Path>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.external_agent_home.join("settings.json"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let settings = read_external_settings(&source_settings)?;
let target_config = repo_root.map_or_else(
|| self.codex_home.join("config.toml"),
|repo_root| repo_root.join(".codex").join("config.toml"),
);
if let Some(settings) = settings.as_ref() {
let migrated = build_config_from_external(settings)?;
if !is_empty_toml_table(&migrated) {
let mut should_include = true;
if target_config.exists() {
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {
invalid_data_error(format!("invalid existing config.toml: {err}"))
})?
};
should_include = merge_missing_toml_values(&mut existing, &migrated)?;
}
if should_include {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}",
source_settings.display(),
target_config.display()
),
cwd: cwd.clone(),
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Config,
/*skills_count*/ None,
);
}
}
}
let source_skills = repo_root.map_or_else(
|| self.external_agent_home.join("skills"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
);
let target_skills = repo_root.map_or_else(
|| self.home_target_skills_dir(),
|repo_root| repo_root.join(".agents").join("skills"),
);
let skills_count = count_missing_subdirectories(&source_skills, &target_skills)?;
if skills_count > 0 {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Migrate skills from {} to {}",
source_skills.display(),
target_skills.display()
),
cwd: cwd.clone(),
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Skills,
Some(skills_count),
);
}
let source_agents_md = if let Some(repo_root) = repo_root {
find_repo_agents_md_source(repo_root)?
} else {
let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD);
is_non_empty_text_file(&path)?.then_some(path)
};
let target_agents_md = repo_root.map_or_else(
|| self.codex_home.join("AGENTS.md"),
|repo_root| repo_root.join("AGENTS.md"),
);
if let Some(source_agents_md) = source_agents_md
&& is_missing_or_empty_text_file(&target_agents_md)?
{
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
source_agents_md.display(),
target_agents_md.display()
),
cwd: cwd.clone(),
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::AgentsMd,
/*skills_count*/ None,
);
}
if let Some(settings) = settings.as_ref() {
match ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.fallback_cwd(Some(self.codex_home.clone()))
.build()
.await
{
Ok(config) => {
let configured_plugin_ids = config
.config_layer_stack
.get_user_layer()
.and_then(|user_layer| user_layer.config.get("plugins"))
.and_then(|plugins| {
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {
Ok(plugins) => Some(plugins),
Err(err) => {
tracing::warn!("invalid plugins config: {err}");
None
}
}
})
.map(|plugins| plugins.into_keys().collect::<HashSet<_>>())
.unwrap_or_default();
let configured_marketplace_plugins = configured_marketplace_plugins(
&config,
&PluginsManager::new(self.codex_home.clone()),
)?;
if let Some(item) = self.detect_plugin_migration(
source_settings.as_path(),
repo_root.unwrap_or(self.external_agent_home.as_path()),
cwd.clone(),
settings,
&configured_plugin_ids,
&configured_marketplace_plugins,
) {
items.push(item);
}
}
Err(err) => {
tracing::warn!(
error = %err,
settings_path = %source_settings.display(),
"skipping external agent plugin migration detection because config load failed"
);
}
}
}
Ok(())
}
fn home_target_skills_dir(&self) -> PathBuf {
self.codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
}
fn detect_plugin_migration(
&self,
source_settings: &Path,
source_root: &Path,
cwd: Option<PathBuf>,
settings: &JsonValue,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<ExternalAgentConfigMigrationItem> {
let plugin_details = extract_plugin_migration_details(
settings,
source_root,
configured_plugin_ids,
configured_marketplace_plugins,
)?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
Some(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!("Migrate enabled plugins from {}", source_settings.display()),
cwd,
details: Some(plugin_details),
})
}
fn partition_plugin_migration_details(
&self,
cwd: Option<&Path>,
details: MigrationDetails,
) -> io::Result<(Option<MigrationDetails>, Option<MigrationDetails>)> {
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = read_external_settings(&source_settings)?
.map(|settings| collect_marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
let mut local_plugins = Vec::new();
let mut remote_plugins = Vec::new();
for plugin_group in details.plugins {
let is_local = import_sources
.get(&plugin_group.marketplace_name)
.and_then(|import_source| {
is_local_marketplace_source(
&import_source.source,
import_source.ref_name.clone(),
)
.ok()
})
.unwrap_or(false);
if is_local {
local_plugins.push(plugin_group);
} else {
remote_plugins.push(plugin_group);
}
}
let local_details = (!local_plugins.is_empty()).then_some(MigrationDetails {
plugins: local_plugins,
});
let remote_details = (!remote_plugins.is_empty()).then_some(MigrationDetails {
plugins: remote_plugins,
});
Ok((local_details, remote_details))
}
pub(crate) async fn import_plugins(
&self,
cwd: Option<&Path>,
details: Option<MigrationDetails>,
) -> io::Result<PluginImportOutcome> {
let Some(MigrationDetails { plugins }) = details else {
return Err(invalid_data_error(
"plugins migration item is missing details".to_string(),
));
};
let mut outcome = PluginImportOutcome::default();
let plugins_manager = PluginsManager::new(self.codex_home.clone());
for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name.clone();
let plugin_names = plugin_group.plugin_names;
let plugin_ids = plugin_names
.iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>();
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_source = read_external_settings(&source_settings)?.and_then(|settings| {
collect_marketplace_import_sources(&settings, source_root).remove(&marketplace_name)
});
let Some(import_source) = import_source else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
let request = MarketplaceAddRequest {
source: import_source.source,
ref_name: import_source.ref_name,
sparse_paths: Vec::new(),
};
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
let marketplace_path = match add_marketplace_outcome {
Ok(add_marketplace_outcome) => {
let Some(marketplace_path) = find_marketplace_manifest_path(
add_marketplace_outcome.installed_root.as_path(),
) else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
marketplace_path
}
Err(_) => {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
}
};
for plugin_name in plugin_names {
match plugins_manager
.install_plugin(PluginInstallRequest {
plugin_name: plugin_name.clone(),
marketplace_path: marketplace_path.clone(),
})
.await
{
Ok(_) => outcome
.succeeded_plugin_ids
.push(format!("{plugin_name}@{marketplace_name}")),
Err(_) => outcome
.failed_plugin_ids
.push(format!("{plugin_name}@{marketplace_name}")),
}
}
}
Ok(outcome)
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.external_agent_home.join("settings.json"),
self.codex_home.join("config.toml"),
)
};
if !source_settings.is_file() {
return Ok(());
}
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
let migrated = build_config_from_external(&settings)?;
if is_empty_toml_table(&migrated) {
return Ok(());
}
let Some(target_parent) = target_config.parent() else {
return Err(invalid_data_error("config target path has no parent"));
};
fs::create_dir_all(target_parent)?;
if !target_config.exists() {
write_toml_file(&target_config, &migrated)?;
return Ok(());
}
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
let changed = merge_missing_toml_values(&mut existing, &migrated)?;
if !changed {
return Ok(());
}
write_toml_file(&target_config, &existing)?;
Ok(())
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<usize> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
} else {
(
self.external_agent_home.join("skills"),
self.home_target_skills_dir(),
)
};
if !source_skills.is_dir() {
return Ok(0);
}
fs::create_dir_all(&target_skills)?;
let mut copied_count = 0usize;
for entry in fs::read_dir(&source_skills)? {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let target = target_skills.join(entry.file_name());
if target.exists() {
continue;
}
copy_dir_recursive(&entry.path(), &target)?;
copied_count += 1;
}
Ok(copied_count)
}
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? {
let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else {
return Ok(());
};
(source_agents_md, repo_root.join("AGENTS.md"))
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
self.codex_home.join("AGENTS.md"),
)
};
if !is_non_empty_text_file(&source_agents_md)?
|| !is_missing_or_empty_text_file(&target_agents_md)?
{
return Ok(());
}
let Some(target_parent) = target_agents_md.parent() else {
return Err(invalid_data_error("AGENTS.md target path has no parent"));
};
fs::create_dir_all(target_parent)?;
rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)
}
}
fn default_external_agent_home() -> PathBuf {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(EXTERNAL_AGENT_DIR);
}
PathBuf::from(EXTERNAL_AGENT_DIR)
}
fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
if !path.is_file() {
return Ok(None);
}
let raw_settings = fs::read_to_string(path)?;
let settings =
serde_json::from_str(&raw_settings).map_err(|err| invalid_data_error(err.to_string()))?;
Ok(Some(settings))
}
fn extract_plugin_migration_details(
settings: &JsonValue,
source_root: &Path,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<MigrationDetails> {
let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root)
.into_iter()
.filter_map(|(marketplace_name, source)| {
is_local_marketplace_source(&source.source, source.ref_name)
.ok()
.map(|_| marketplace_name)
})
.collect::<HashSet<_>>();
let mut plugins = BTreeMap::new();
for plugin_id in collect_enabled_plugins(settings)
.into_iter()
.filter(|plugin_id| !configured_plugin_ids.contains(plugin_id))
{
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
if let Some(installable_plugins) =
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
{
if !installable_plugins.contains(&plugin_id.plugin_name) {
continue;
}
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
continue;
}
let plugin_group = plugins
.entry(plugin_id.marketplace_name.clone())
.or_insert_with(|| PluginsMigration {
marketplace_name: plugin_id.marketplace_name.clone(),
plugin_names: Vec::new(),
});
plugin_group.plugin_names.push(plugin_id.plugin_name);
}
let plugins = plugins
.into_values()
.filter_map(|mut plugin_group| {
if plugin_group.plugin_names.is_empty() {
return None;
}
plugin_group.plugin_names.sort();
Some(plugin_group)
})
.collect::<Vec<_>>();
if plugins.is_empty() {
return None;
}
Some(MigrationDetails { plugins })
}
fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
let Some(enabled_plugins) = settings
.as_object()
.and_then(|settings| settings.get("enabledPlugins"))
.and_then(JsonValue::as_object)
else {
return Vec::new();
};
enabled_plugins
.iter()
.filter_map(|(plugin_key, enabled)| {
if !enabled.as_bool().unwrap_or(false) {
return None;
}
PluginId::parse(plugin_key)
.ok()
.map(|plugin_id| plugin_id.as_key())
})
.collect()
}
fn has_enabled_plugin_for_marketplace(settings: &JsonValue, marketplace_name: &str) -> bool {
collect_enabled_plugins(settings)
.into_iter()
.any(|plugin_id| {
PluginId::parse(&plugin_id)
.map(|plugin_id| plugin_id.marketplace_name == marketplace_name)
.unwrap_or(false)
})
}
fn configured_marketplace_plugins(
config: &Config,
plugins_manager: &PluginsManager,
) -> io::Result<BTreeMap<String, HashSet<String>>> {
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.map_err(|err| {
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
})?;
let mut marketplace_plugins = BTreeMap::new();
for marketplace in marketplaces.marketplaces {
let plugins = marketplace
.plugins
.into_iter()
.filter(|plugin| {
plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable
})
.filter(|plugin| {
plugin
.policy
.products
.as_deref()
.is_none_or(|products| Product::Codex.matches_product_restriction(products))
})
.map(|plugin| plugin.name)
.collect::<HashSet<_>>();
marketplace_plugins.insert(marketplace.name, plugins);
}
Ok(marketplace_plugins)
}
fn collect_marketplace_import_sources(
settings: &JsonValue,
source_root: &Path,
) -> BTreeMap<String, MarketplaceImportSource> {
let mut import_sources: BTreeMap<String, MarketplaceImportSource> = settings
.as_object()
.and_then(|settings| settings.get("extraKnownMarketplaces"))
.and_then(JsonValue::as_object)
.map(|extra_known_marketplaces| {
extra_known_marketplaces
.iter()
.filter_map(|(name, value)| {
let source_fields = if let Some(source) = value.get("source")
&& source.is_object()
{
source.as_object()?
} else {
value.as_object()?
};
let source = source_fields
.get("repo")
.or_else(|| source_fields.get("url"))
.or_else(|| source_fields.get("path"))
.or_else(|| value.get("source"))?
.as_str()?
.trim()
.to_string();
if source.is_empty() {
return None;
}
let source = resolve_external_marketplace_source(&source, source_root);
let ref_name = source_fields
.get("ref")
.or_else(|| value.get("ref"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
})
.collect()
})
.unwrap_or_default();
if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME)
&& !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME)
{
import_sources.insert(
EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
MarketplaceImportSource {
source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(),
ref_name: None,
},
);
}
import_sources
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MarketplaceImportSource {
source: String,
ref_name: Option<String>,
}
fn resolve_external_marketplace_source(source: &str, source_root: &Path) -> String {
if !looks_like_relative_local_path(source) {
return source.to_string();
}
source_root.join(source).display().to_string()
}
fn looks_like_relative_local_path(source: &str) -> bool {
source.starts_with("./") || source.starts_with("../") || source == "." || source == ".."
}
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
return Ok(None);
};
let mut current = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::env::current_dir()?.join(cwd)
};
if !current.exists() {
return Ok(None);
}
if current.is_file() {
let Some(parent) = current.parent() else {
return Ok(None);
};
current = parent.to_path_buf();
}
let fallback = current.clone();
loop {
let git_path = current.join(".git");
if git_path.is_dir() || git_path.is_file() {
return Ok(Some(current));
}
if !current.pop() {
break;
}
}
Ok(Some(fallback))
}
fn collect_subdirectory_names(path: &Path) -> io::Result<HashSet<OsString>> {
let mut names = HashSet::new();
if !path.is_dir() {
return Ok(names);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
names.insert(entry.file_name());
}
}
Ok(names)
}
fn count_missing_subdirectories(source: &Path, target: &Path) -> io::Result<usize> {
let source_names = collect_subdirectory_names(source)?;
let target_names = collect_subdirectory_names(target)?;
Ok(source_names
.iter()
.filter(|name| !target_names.contains(*name))
.count())
}
fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {
if !path.exists() {
return Ok(true);
}
if !path.is_file() {
return Ok(false);
}
Ok(fs::read_to_string(path)?.trim().is_empty())
}
fn is_non_empty_text_file(path: &Path) -> io::Result<bool> {
if !path.is_file() {
return Ok(false);
}
Ok(!fs::read_to_string(path)?.trim().is_empty())
}
fn find_repo_agents_md_source(repo_root: &Path) -> io::Result<Option<PathBuf>> {
for candidate in [
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD),
] {
if is_non_empty_text_file(&candidate)? {
return Ok(Some(candidate));
}
}
Ok(None)
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
fn is_skill_md(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
}
fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_external_agent_terms(&source_contents);
fs::write(target, rewritten)
}
fn rewrite_external_agent_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
&EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(),
"AGENTS.md",
);
for from in [
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
] {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
rewritten
}
fn replace_case_insensitive_with_boundaries(
input: &str,
needle: &str,
replacement: &str,
) -> String {
let needle_lower = needle.to_ascii_lowercase();
if needle_lower.is_empty() {
return input.to_string();
}
let haystack_lower = input.to_ascii_lowercase();
let bytes = input.as_bytes();
let mut output = String::with_capacity(input.len());
let mut last_emitted = 0usize;
let mut search_start = 0usize;
while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {
let start = search_start + relative_pos;
let end = start + needle_lower.len();
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = start + 1;
}
if last_emitted == 0 {
return input.to_string();
}
output.push_str(&input[last_emitted..]);
output
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn build_config_from_external(settings: &JsonValue) -> io::Result<TomlValue> {
let Some(settings_obj) = settings.as_object() else {
return Err(invalid_data_error(
"external agent settings root must be an object",
));
};
let mut root = toml::map::Map::new();
if let Some(env) = settings_obj.get("env").and_then(JsonValue::as_object)
&& !env.is_empty()
{
let mut shell_policy = toml::map::Map::new();
shell_policy.insert("inherit".to_string(), TomlValue::String("core".to_string()));
shell_policy.insert(
"set".to_string(),
TomlValue::Table(json_object_to_env_toml_table(env)),
);
root.insert(
"shell_environment_policy".to_string(),
TomlValue::Table(shell_policy),
);
}
if let Some(sandbox_enabled) = settings_obj
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
&& sandbox_enabled
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
Ok(TomlValue::Table(root))
}
fn json_object_to_env_toml_table(
object: &serde_json::Map<String, JsonValue>,
) -> toml::map::Map<String, TomlValue> {
let mut table = toml::map::Map::new();
for (key, value) in object {
if let Some(value) = json_env_value_to_string(value) {
table.insert(key.clone(), TomlValue::String(value));
}
}
table
}
fn json_env_value_to_string(value: &JsonValue) -> Option<String> {
match value {
JsonValue::String(value) => Some(value.clone()),
JsonValue::Null => None,
JsonValue::Bool(value) => Some(value.to_string()),
JsonValue::Number(value) => Some(value.to_string()),
JsonValue::Array(_) | JsonValue::Object(_) => None,
}
}
fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> io::Result<bool> {
match (existing, incoming) {
(TomlValue::Table(existing_table), TomlValue::Table(incoming_table)) => {
let mut changed = false;
for (key, incoming_value) in incoming_table {
match existing_table.get_mut(key) {
Some(existing_value) => {
if matches!(
(&*existing_value, incoming_value),
(TomlValue::Table(_), TomlValue::Table(_))
) && merge_missing_toml_values(existing_value, incoming_value)?
{
changed = true;
}
}
None => {
existing_table.insert(key.clone(), incoming_value.clone());
changed = true;
}
}
}
Ok(changed)
}
_ => Err(invalid_data_error(
"expected TOML table while merging migrated config values",
)),
}
}
fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
let serialized = toml::to_string_pretty(value)
.map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;
fs::write(path, format!("{}\n", serialized.trim_end()))
}
fn is_empty_toml_table(value: &TomlValue) -> bool {
match value {
TomlValue::Table(table) => table.is_empty(),
TomlValue::String(_)
| TomlValue::Integer(_)
| TomlValue::Float(_)
| TomlValue::Boolean(_)
| TomlValue::Datetime(_)
| TomlValue::Array(_) => false,
}
}
fn invalid_data_error(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn migration_metric_tags(
item_type: ExternalAgentConfigMigrationItemType,
skills_count: Option<usize>,
) -> Vec<(&'static str, String)> {
let migration_type = match item_type {
ExternalAgentConfigMigrationItemType::Config => "config",
ExternalAgentConfigMigrationItemType::Skills => "skills",
ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md",
ExternalAgentConfigMigrationItemType::Plugins => "plugins",
ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config",
};
let mut tags = vec![("migration_type", migration_type.to_string())];
if item_type == ExternalAgentConfigMigrationItemType::Skills {
tags.push(("skills_count", skills_count.unwrap_or(0).to_string()));
}
tags
}
fn emit_migration_metric(
metric_name: &str,
item_type: ExternalAgentConfigMigrationItemType,
skills_count: Option<usize>,
) {
let Some(metrics) = codex_otel::global() else {
return;
};
let tags = migration_metric_tags(item_type, skills_count);
let tag_refs = tags
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect::<Vec<_>>();
let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
}
#[cfg(test)]
#[path = "external_agent_config_tests.rs"]
mod tests;