Prepare external agent migration for source adapters (#32884)

## What changed

- Add an optional `source` selector to `externalAgentConfig/detect`, while preserving the existing behavior for omitted, `null`, and unrecognized values.
- Pass `claude-code` as the source for TUI detection and import requests.
- Move source-specific migration behavior behind an adapter boundary and consolidate session migration into `codex-external-agent-migration`.

## Testing

- Cover explicit, omitted, and unknown detection source values with app-server protocol tests.
- Add unit coverage for the extracted migration and hook helpers.

GitOrigin-RevId: 63bd3dcb485f3ad7bd996022db4e351506aeea77
This commit is contained in:
charlesgong-openai
2026-07-13 22:15:55 +00:00
committed by copyberry
parent 0877afbe85
commit bc8222b8d9
42 changed files with 2713 additions and 1850 deletions
-1
View File
@@ -1 +0,0 @@
pub(crate) mod external_agent_config;
@@ -0,0 +1,6 @@
mod processor;
mod service;
mod session_importer;
pub(crate) use processor::ExternalAgentConfigRequestProcessor;
pub(crate) use processor::ExternalAgentConfigRequestProcessorArgs;
@@ -1,20 +1,10 @@
use std::sync::Arc;
use crate::config::external_agent_config::ExternalAgentConfigDetectOptions;
use crate::config::external_agent_config::ExternalAgentConfigImportItemResult as CoreImportItemResult;
use crate::config::external_agent_config::ExternalAgentConfigImportOutcome as CoreImportOutcome;
use crate::config::external_agent_config::ExternalAgentConfigImportRawError as CoreImportRawError;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use crate::config::external_agent_config::ExternalAgentConfigService;
use crate::config::external_agent_config::NamedMigration as CoreNamedMigration;
use crate::config::external_agent_config::PendingPluginImport;
use crate::config::external_agent_config::PluginImportOutcome;
use crate::config::external_agent_config::record_import_error;
use crate::config_manager::ConfigManager;
use crate::error_code::internal_error;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::request_processors::ConfigRequestProcessor;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::ExternalAgentConfigImportCompletedInput;
use codex_analytics::ExternalAgentConfigImportFailureInput;
@@ -41,7 +31,7 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SkillMigration;
use codex_arg0::Arg0DispatchPaths;
use codex_core::ThreadManager;
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration as CoreSessionMigration;
use codex_rollout::StateDbHandle;
use codex_state::ExternalAgentConfigImportFailureRecord;
use codex_state::ExternalAgentConfigImportSuccessRecord;
@@ -49,8 +39,18 @@ use codex_thread_store::ThreadStore;
use std::collections::HashSet;
use std::path::PathBuf;
use super::ConfigRequestProcessor;
use super::external_agent_session_import::ExternalAgentSessionImporter;
use super::service::ExternalAgentConfigDetectOptions;
use super::service::ExternalAgentConfigImportItemResult as CoreImportItemResult;
use super::service::ExternalAgentConfigImportOutcome as CoreImportOutcome;
use super::service::ExternalAgentConfigImportRawError as CoreImportRawError;
use super::service::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use super::service::ExternalAgentConfigService;
use super::service::NamedMigration as CoreNamedMigration;
use super::service::PendingPluginImport;
use super::service::PluginImportOutcome;
use super::service::record_import_error;
use super::session_importer::ExternalAgentSessionImporter;
use uuid::Uuid;
#[derive(Clone)]
@@ -487,15 +487,13 @@ impl ExternalAgentConfigRequestProcessor {
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| {
crate::config::external_agent_config::MigrationDetails {
super::service::MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| {
crate::config::external_agent_config::PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
}
.map(|plugin| super::service::PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
skills: details
@@ -832,7 +830,7 @@ fn protocol_import_type_result(item_result: &CoreImportItemResult) -> ProtocolIm
}
fn protocol_import_success(
success: &crate::config::external_agent_config::ExternalAgentConfigImportSuccess,
success: &super::service::ExternalAgentConfigImportSuccess,
) -> ProtocolImportSuccess {
ProtocolImportSuccess {
item_type: protocol_migration_item_type(success.item_type),
@@ -898,5 +896,5 @@ fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationIte
}
#[cfg(test)]
#[path = "external_agent_config_processor_tests.rs"]
mod external_agent_config_processor_tests;
#[path = "processor_tests.rs"]
mod tests;
@@ -0,0 +1,242 @@
use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_external_agent_migration::sessions::detect_recent_cla_sessions;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
use super::MigrationDetails;
use super::source_cla;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct InstructionSourceGroup {
pub(super) scope: PathBuf,
pub(super) sources: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MarketplaceImportSource {
pub(super) source: String,
pub(super) ref_name: Option<String>,
}
pub(super) struct DetectedSourcePlugins {
pub(super) description: String,
pub(super) details: MigrationDetails,
}
pub(super) struct PluginDetectionContext<'a> {
pub(super) external_agent_home: &'a Path,
pub(super) source_settings: &'a Path,
pub(super) source_root: &'a Path,
pub(super) settings: Option<&'a JsonValue>,
pub(super) configured_plugin_ids: &'a HashSet<String>,
pub(super) configured_marketplace_plugins: &'a BTreeMap<String, HashSet<String>>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) enum ExternalAgentSource {
#[default]
Cla,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SourceFeature {
Config,
Plugins,
Sessions,
}
impl ExternalAgentSource {
pub(super) fn config_dir(self) -> &'static str {
match self {
Self::Cla => source_cla::CONFIG_DIR,
}
}
pub(super) fn supports(self, feature: SourceFeature) -> bool {
match (self, feature) {
(
Self::Cla,
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
) => true,
}
}
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
match (self, project_scope) {
(Self::Cla, _) => "settings.json",
}
}
pub(super) fn effective_settings(
self,
source_settings: &Path,
) -> io::Result<Option<JsonValue>> {
match self {
Self::Cla => source_cla::effective_settings(source_settings),
}
}
pub(super) fn detect_plugins(
self,
context: PluginDetectionContext<'_>,
) -> io::Result<Option<DetectedSourcePlugins>> {
match self {
Self::Cla => Ok(source_cla::detect_plugins(&context)),
}
}
pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool {
match self {
Self::Cla => source_cla::can_detect_plugins(settings),
}
}
pub(super) fn detect_recent_sessions(
self,
external_agent_home: &Path,
codex_home: &Path,
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
match self {
Self::Cla => detect_recent_cla_sessions(external_agent_home, codex_home),
}
}
pub(super) fn marketplace_import_sources(
self,
external_agent_home: &Path,
source_root: &Path,
source_settings: &Path,
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
match self {
Self::Cla => Ok(source_cla::effective_settings(source_settings)?
.as_ref()
.map(|settings| {
source_cla::marketplace_import_sources(
settings,
external_agent_home,
source_root,
)
})
.unwrap_or_default()),
}
}
pub(super) fn append_config(
self,
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
match self {
Self::Cla => source_cla::append_config(root, settings),
}
}
pub(super) fn build_mcp_config(
self,
source_root: &Path,
external_agent_home: &Path,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
match self {
Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings),
}
}
pub(super) fn mcp_source_path(self, source_root: PathBuf) -> PathBuf {
match self {
Self::Cla => source_root,
}
}
pub(super) fn repo_instruction_source_groups(
self,
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
match self {
Self::Cla => source_cla::repo_instruction_source_groups(repo_root),
}
}
pub(super) fn home_instruction_sources(
self,
external_agent_home: &Path,
) -> io::Result<Vec<PathBuf>> {
match self {
Self::Cla => source_cla::home_instruction_sources(external_agent_home),
}
}
pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> {
match self {
Self::Cla => source_cla::read_instruction_source(path),
}
}
pub(super) fn import_commands(
self,
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
match self {
Self::Cla => source_cla::import_source_commands(source_commands, target_skills),
}
}
pub(super) fn count_missing_commands(
self,
source_commands: &Path,
target_skills: &Path,
) -> io::Result<usize> {
match self {
Self::Cla => source_cla::count_missing_source_commands(source_commands, target_skills),
}
}
pub(super) fn missing_command_names(
self,
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
match self {
Self::Cla => source_cla::missing_source_command_names(source_commands, target_skills),
}
}
pub(super) fn import_subagents(
self,
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
match self {
Self::Cla => source_cla::import_source_subagents(source_agents, target_agents),
}
}
pub(super) fn hook_event_names(
self,
source_dir: &Path,
target_hooks: &Path,
) -> io::Result<Vec<String>> {
match self {
Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks),
}
}
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
match self {
Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks),
}
}
pub(super) fn rewrite_profile(self) -> RewriteProfile {
match self {
Self::Cla => source_cla::REWRITE_PROFILE,
}
}
}
@@ -0,0 +1,483 @@
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_external_agent_migration::CommandDescriptionMode;
use codex_external_agent_migration::CommandMigrationProfile;
use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::build_mcp_config_from_external;
use codex_external_agent_migration::count_missing_commands_with_profile;
use codex_external_agent_migration::hook_migration_event_names_cla;
use codex_external_agent_migration::import_commands_with_profile;
use codex_external_agent_migration::import_hooks_cla;
use codex_external_agent_migration::import_subagents_with_rewrite_profile;
use codex_external_agent_migration::missing_command_names_with_profile;
use codex_plugin::PluginId;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
use super::MigrationDetails;
use super::PluginsMigration;
use super::is_non_empty_text_file;
use super::source::DetectedSourcePlugins;
use super::source::InstructionSourceGroup;
use super::source::MarketplaceImportSource;
use super::source::PluginDetectionContext;
pub(super) const CONFIG_DIR: &str = ".claude";
pub(super) const CONFIG_MD: &str = "CLAUDE.md";
pub(super) const KNOWN_MARKETPLACES_PATH: &str = "plugins/known_marketplaces.json";
pub(super) const OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
pub(super) const OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
pub(super) const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
CONFIG_MD,
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
);
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile =
CommandMigrationProfile::new(REWRITE_PROFILE, CommandDescriptionMode::RequireFrontmatter);
pub(super) fn effective_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {
let mut effective = super::read_external_settings(project_settings)?;
let Some(settings_dir) = project_settings.parent() else {
return Ok(effective);
};
let local_settings = settings_dir.join("settings.local.json");
let local_settings = match super::read_external_settings(&local_settings) {
Ok(Some(local_settings)) => local_settings,
Ok(None) => return Ok(effective),
Err(err) if err.kind() == io::ErrorKind::InvalidData => return Ok(effective),
Err(err) => return Err(err),
};
if let Some(effective) = effective.as_mut() {
merge_json_settings(effective, &local_settings);
} else {
effective = Some(local_settings);
}
Ok(effective)
}
pub(super) fn append_config(
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
if settings
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
== Some(true)
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
}
pub(super) fn detect_plugins(
context: &PluginDetectionContext<'_>,
) -> Option<DetectedSourcePlugins> {
let settings = context.settings?;
let import_sources =
marketplace_import_sources(settings, context.external_agent_home, context.source_root);
let details = extract_plugin_migration_details(
settings,
&import_sources,
context.configured_plugin_ids,
context.configured_marketplace_plugins,
)?;
Some(DetectedSourcePlugins {
description: format!(
"Migrate enabled plugins from {}",
context.source_settings.display()
),
details,
})
}
pub(super) fn can_detect_plugins(settings: Option<&JsonValue>) -> bool {
settings.is_some()
}
pub(super) fn marketplace_import_sources(
settings: &JsonValue,
external_agent_home: &Path,
source_root: &Path,
) -> BTreeMap<String, MarketplaceImportSource> {
let known_marketplaces_path = external_agent_home.join(KNOWN_MARKETPLACES_PATH);
let known_marketplaces = match super::read_external_settings(&known_marketplaces_path) {
Ok(known_marketplaces) => known_marketplaces,
Err(err) => {
tracing::warn!(
path = %known_marketplaces_path.display(),
error = %err,
"ignoring invalid external agent marketplace registry"
);
None
}
};
let mut import_sources = known_marketplaces
.as_ref()
.map(|known_marketplaces| {
collect_marketplace_import_sources(known_marketplaces, external_agent_home)
})
.unwrap_or_default();
if let Some(extra_known_marketplaces) = settings
.as_object()
.and_then(|settings| settings.get("extraKnownMarketplaces"))
{
let mut scoped_marketplaces = extra_known_marketplaces.clone();
if let Some(scoped_marketplaces) = scoped_marketplaces.as_object_mut() {
for (name, scoped_marketplace) in scoped_marketplaces {
import_sources.remove(name);
let Some(known_marketplace) = known_marketplaces
.as_ref()
.and_then(JsonValue::as_object)
.and_then(|known_marketplaces| known_marketplaces.get(name))
else {
continue;
};
if scoped_marketplace.get("source") != known_marketplace.get("source") {
continue;
}
let Some(install_location) = known_marketplace
.get("installLocation")
.and_then(JsonValue::as_str)
else {
continue;
};
let install_location = Path::new(install_location);
let install_location = if install_location.is_absolute() {
install_location.to_path_buf()
} else {
external_agent_home.join(install_location)
};
let Some(scoped_marketplace) = scoped_marketplace.as_object_mut() else {
continue;
};
scoped_marketplace.insert(
"installLocation".to_string(),
JsonValue::String(install_location.display().to_string()),
);
}
}
import_sources.extend(collect_marketplace_import_sources(
&scoped_marketplaces,
source_root,
));
}
if has_enabled_plugin_for_marketplace(settings, OFFICIAL_MARKETPLACE_NAME)
&& !import_sources.contains_key(OFFICIAL_MARKETPLACE_NAME)
{
import_sources.insert(
OFFICIAL_MARKETPLACE_NAME.to_string(),
MarketplaceImportSource {
source: OFFICIAL_MARKETPLACE_SOURCE.to_string(),
ref_name: None,
},
);
}
import_sources
}
pub(super) fn build_mcp_config(
source_root: &Path,
external_agent_home: &Path,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
build_mcp_config_from_external(source_root, Some(external_agent_home), settings)
}
pub(super) fn repo_instruction_source_groups(
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
for candidate in [
repo_root.join(CONFIG_MD),
repo_root.join(CONFIG_DIR).join(CONFIG_MD),
] {
if is_non_empty_text_file(&candidate)? {
return Ok(vec![InstructionSourceGroup {
scope: repo_root.to_path_buf(),
sources: vec![candidate],
}]);
}
}
Ok(Vec::new())
}
pub(super) fn home_instruction_sources(external_agent_home: &Path) -> io::Result<Vec<PathBuf>> {
let path = external_agent_home.join(CONFIG_MD);
Ok(is_non_empty_text_file(&path)?
.then_some(path)
.into_iter()
.collect())
}
pub(super) fn read_instruction_source(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
pub(super) fn import_source_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
import_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn count_missing_source_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<usize> {
count_missing_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn missing_source_command_names(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
missing_command_names_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn import_source_subagents(
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
import_subagents_with_rewrite_profile(source_agents, target_agents, REWRITE_PROFILE)
}
pub(super) fn source_hook_event_names(
source_dir: &Path,
target_hooks: &Path,
) -> io::Result<Vec<String>> {
hook_migration_event_names_cla(source_dir, target_hooks, REWRITE_PROFILE)
}
pub(super) fn import_source_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
import_hooks_cla(source_dir, target_hooks, REWRITE_PROFILE)
}
fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) {
match (existing, incoming) {
(JsonValue::Object(existing), JsonValue::Object(incoming)) => {
for (key, incoming_value) in incoming {
match existing.get_mut(key) {
Some(existing_value) => merge_json_settings(existing_value, incoming_value),
None => {
existing.insert(key.clone(), incoming_value.clone());
}
}
}
}
(existing, incoming) => {
*existing = incoming.clone();
}
}
}
fn extract_plugin_migration_details(
settings: &JsonValue,
import_sources: &BTreeMap<String, MarketplaceImportSource>,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<MigrationDetails> {
let loadable_marketplaces = import_sources
.iter()
.filter_map(|(marketplace_name, source)| {
is_local_marketplace_source(&source.source, source.ref_name.clone())
.ok()
.map(|_| marketplace_name.clone())
})
.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) {
tracing::warn!(
plugin_id = %plugin_id.as_key(),
marketplace_name = %plugin_id.marketplace_name,
"enabled external agent plugin was not found in configured marketplace"
);
continue;
}
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
tracing::warn!(
plugin_id = %plugin_id.as_key(),
marketplace_name = %plugin_id.marketplace_name,
"marketplace source was not found for enabled external agent plugin"
);
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,
..Default::default()
})
}
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 collect_marketplace_import_sources(
marketplaces: &JsonValue,
source_root: &Path,
) -> BTreeMap<String, MarketplaceImportSource> {
marketplaces
.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_kind = source_fields
.get("source")
.and_then(JsonValue::as_str)
.map(str::trim);
let declared_source = match source_kind {
Some("github") => source_fields.get("repo"),
Some("git") => source_fields.get("url"),
Some("directory" | "local") => source_fields.get("path"),
Some("file" | "url" | "npm" | "settings") => None,
Some(_) => source_fields.get("source"),
None => source_fields
.get("repo")
.or_else(|| source_fields.get("url"))
.or_else(|| source_fields.get("path"))
.or_else(|| value.get("source")),
}
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let materialized_source = value
.get("installLocation")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| {
let path = Path::new(value);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
source_root.join(path)
};
path.is_dir().then(|| path.display().to_string())
});
let (source, ref_name) = if let Some(source) = declared_source {
let source = if matches!(source_kind, Some("directory" | "local")) {
let path = Path::new(source);
if path.is_absolute() {
path.to_path_buf()
} else {
source_root.join(path)
}
.display()
.to_string()
} else {
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);
(source, ref_name)
} else {
(materialized_source?, None)
};
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
})
.collect()
})
.unwrap_or_default()
}
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 == ".."
}
@@ -0,0 +1,139 @@
use codex_external_agent_migration::RewriteProfile;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub(super) fn display_source_paths(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub(super) fn copy_dir_recursive(
source: &Path,
target: &Path,
rewrite_profile: RewriteProfile,
) -> 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, rewrite_profile)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path, rewrite_profile)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
pub(super) fn rewrite_external_agent_terms(
content: &str,
rewrite_profile: RewriteProfile,
) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
rewrite_profile.doc_file_name(),
"AGENTS.md",
);
for from in rewrite_profile.term_variants() {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
for from in rewrite_profile.case_sensitive_term_variants() {
rewritten = replace_with_boundaries(&rewritten, from, "Codex");
}
rewritten
}
fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String {
if needle.is_empty() {
return input.to_string();
}
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) = input[search_start..].find(needle) {
let start = search_start + relative_pos;
let end = start + needle.len();
let boundary_before = start == 0 || !is_word_char(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_char(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = end;
}
output.push_str(&input[last_emitted..]);
output
}
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,
rewrite_profile: RewriteProfile,
) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_external_agent_terms(&source_contents, rewrite_profile);
fs::write(target, 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_char(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_char(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = end;
}
output.push_str(&input[last_emitted..]);
output
}
fn is_word_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
@@ -1192,10 +1192,12 @@ async fn detect_home_skips_skills_when_all_skill_directories_exist() {
}
#[tokio::test]
async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
async fn import_repo_agents_md_from_nested_cwd_rewrites_terms_and_skips_non_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo-a");
let nested_cwd = repo_root.join("nested");
let repo_with_existing_target = root.path().join("repo-b");
fs::create_dir_all(&nested_cwd).expect("create nested cwd");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git");
fs::write(
@@ -1224,7 +1226,7 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
cwd: Some(nested_cwd.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
@@ -1242,12 +1244,12 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
cwd: Some(nested_cwd.clone()),
success_count: 1,
error_count: 0,
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::AgentsMd,
Some(repo_root.clone()),
Some(nested_cwd),
repo_root
.join(EXTERNAL_AGENT_CONFIG_MD)
.display()
@@ -1830,7 +1832,7 @@ async fn detect_home_uses_materialized_known_marketplace_for_inline_npm_source()
#[test]
fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision() {
let (root, external_agent_home, codex_home) = fixture_paths();
let (root, external_agent_home, _codex_home) = fixture_paths();
let source_root = root.path().join("repo");
let scoped_marketplace = source_root.join("repo-marketplace");
let cached_marketplace = external_agent_home.join("plugins/marketplaces/debug");
@@ -1861,8 +1863,8 @@ fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision
}
});
let import_sources = service_for_paths(external_agent_home, codex_home)
.marketplace_import_sources(&settings, &source_root);
let import_sources =
source_cla::marketplace_import_sources(&settings, &external_agent_home, &source_root);
assert_eq!(
import_sources.get("debug"),
@@ -1875,7 +1877,7 @@ fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision
#[test]
fn marketplace_import_sources_prefers_supported_declaration_over_materialization() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let (_root, external_agent_home, _codex_home) = fixture_paths();
let cached_marketplace = external_agent_home.join("plugins/marketplaces/acme-tools");
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
fs::write(
@@ -1894,8 +1896,11 @@ fn marketplace_import_sources_prefers_supported_declaration_over_materialization
)
.expect("write known marketplaces");
let import_sources = service_for_paths(external_agent_home.clone(), codex_home)
.marketplace_import_sources(&serde_json::json!({}), &external_agent_home);
let import_sources = source_cla::marketplace_import_sources(
&serde_json::json!({}),
&external_agent_home,
&external_agent_home,
);
assert_eq!(
import_sources.get("acme-tools"),
@@ -5,12 +5,12 @@ use chrono::Utc;
use codex_arg0::Arg0DispatchPaths;
use codex_core::ThreadManager;
use codex_core::config::ConfigOverrides;
use codex_external_agent_sessions::CompletedExternalAgentSessionImport;
use codex_external_agent_sessions::ExternalAgentSessionMigration;
use codex_external_agent_sessions::ImportedExternalAgentSession;
use codex_external_agent_sessions::PendingSessionImport;
use codex_external_agent_sessions::prepare_validated_session_import;
use codex_external_agent_sessions::record_completed_session_imports;
use codex_external_agent_migration::sessions::CompletedExternalAgentSessionImport;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_external_agent_migration::sessions::ImportedExternalAgentSession;
use codex_external_agent_migration::sessions::PendingSessionImport;
use codex_external_agent_migration::sessions::prepare_validated_session_import;
use codex_external_agent_migration::sessions::record_completed_session_imports;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
@@ -27,8 +27,8 @@ use codex_thread_store::UpdateThreadMetadataParams;
use futures::StreamExt;
use tokio::sync::Semaphore;
use crate::config::external_agent_config::ExternalAgentConfigImportItemResult;
use crate::config::external_agent_config::record_import_error;
use super::service::ExternalAgentConfigImportItemResult;
use super::service::record_import_error;
use crate::config_manager::ConfigManager;
const SESSION_IMPORT_CONCURRENCY: usize = 5;
+1 -1
View File
@@ -87,7 +87,6 @@ mod attestation;
mod auth_mode;
mod bespoke_event_handling;
mod command_exec;
mod config;
mod config_layer;
mod config_manager;
mod config_manager_service;
@@ -98,6 +97,7 @@ mod dynamic_tools;
mod effective_plugin_change;
mod error_code;
mod extensions;
mod external_agent_migration;
mod external_auth;
mod filters;
mod fs_watch;
+2 -2
View File
@@ -13,6 +13,8 @@ use crate::extensions::ThreadExtensionDependencies;
use crate::extensions::app_server_extension_event_sink;
use crate::extensions::guardian_agent_spawner;
use crate::extensions::thread_extensions;
use crate::external_agent_migration::ExternalAgentConfigRequestProcessor;
use crate::external_agent_migration::ExternalAgentConfigRequestProcessorArgs;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
@@ -24,8 +26,6 @@ use crate::request_processors::CatalogRequestProcessor;
use crate::request_processors::CommandExecRequestProcessor;
use crate::request_processors::ConfigRequestProcessor;
use crate::request_processors::EnvironmentRequestProcessor;
use crate::request_processors::ExternalAgentConfigRequestProcessor;
use crate::request_processors::ExternalAgentConfigRequestProcessorArgs;
use crate::request_processors::FeedbackRequestProcessor;
use crate::request_processors::FsRequestProcessor;
use crate::request_processors::GitRequestProcessor;
@@ -501,8 +501,6 @@ mod catalog_processor;
mod command_exec_processor;
mod config_processor;
mod environment_processor;
mod external_agent_config_processor;
mod external_agent_session_import;
mod feedback_doctor_report;
mod feedback_processor;
mod fs_processor;
@@ -525,8 +523,6 @@ pub(crate) use catalog_processor::CatalogRequestProcessor;
pub(crate) use command_exec_processor::CommandExecRequestProcessor;
pub(crate) use config_processor::ConfigRequestProcessor;
pub(crate) use environment_processor::EnvironmentRequestProcessor;
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor;
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessorArgs;
pub(crate) use feedback_processor::FeedbackRequestProcessor;
pub(crate) use fs_processor::FsRequestProcessor;
pub(crate) use git_processor::GitRequestProcessor;