Files
codex/codex-rs/external-agent-migration/src/lib.rs
alexsong-oai d92c909ee4 Fix migrated hook path rewriting (#20144)
## Summary
- Rewrite migrated external-agent hook commands by replacing the full
hook script path token instead of only the `.claude/hooks/` segment.
- Preserve quoting around the full rewritten target path so script names
with spaces, absolute paths, and shell operators/redirection continue to
work.
- Apply `.claude/settings.local.json` over `.claude/settings.json` for
config, MCP, and plugin migration so local scope matches Claude settings
precedence.
- Skip legacy command markdown without `description` frontmatter,
including README-style docs under `.claude/commands`.

## Root Cause
The previous hook rewrite handled `.claude/hooks/` as a substring
replacement. For absolute source commands, that left the original
project-root prefix before the newly quoted `.codex/hooks` directory,
producing invalid commands like
`project/'project/.codex/hooks'/script.sh`.

The migration also only used project `settings.json` for
config/MCP/plugin decisions, so local settings such as
`disabledMcpjsonServers` could be ignored even though Claude gives local
settings higher precedence than project settings.

## Validation
- `just fmt`
- `cargo test -p codex-external-agent-migration`
- `cargo test -p codex-app-server external_agent_config`
- `just fix -p codex-external-agent-migration`
- `just fix -p codex-app-server`
- `git diff --check`
2026-04-29 00:46:11 -07:00

2142 lines
69 KiB
Rust

//! Migration helpers for importing external-agent configuration into Codex.
use codex_hooks::HOOK_EVENT_NAMES;
use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;
use serde_json::Value as JsonValue;
use serde_yaml::Value as YamlValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
const COMMAND_SKILL_PREFIX: &str = "source-command";
const MAX_SKILL_NAME_LEN: usize = 64;
const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug)]
struct ParsedDocument {
frontmatter: BTreeMap<String, FrontmatterValue>,
body: String,
frontmatter_error: Option<String>,
}
#[derive(Debug)]
enum FrontmatterValue {
Scalar(String),
Other,
}
#[derive(Debug)]
struct AgentMetadata {
name: String,
description: String,
permission_mode: Option<String>,
effort: Option<String>,
}
pub fn build_mcp_config_from_external(
source_root: &Path,
external_agent_home: Option<&Path>,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?;
if mcp_servers.is_empty() {
return Ok(TomlValue::Table(Default::default()));
}
let enabled_servers = settings
.and_then(|settings| settings.get("enabledMcpjsonServers"))
.map(json_string_vec)
.unwrap_or_default();
let disabled_servers = settings
.and_then(|settings| settings.get("disabledMcpjsonServers"))
.map(json_string_vec)
.unwrap_or_default()
.into_iter()
.collect::<BTreeSet<_>>();
let mut servers = toml::map::Map::new();
for (server_name, server_config) in mcp_servers {
if let Some(server) = mcp_server_toml_table(
&server_name,
server_config.as_object(),
&enabled_servers,
&disabled_servers,
) {
servers.insert(server_name.clone(), TomlValue::Table(server));
}
}
if servers.is_empty() {
return Ok(TomlValue::Table(Default::default()));
}
let mut root = toml::map::Map::new();
root.insert("mcp_servers".to_string(), TomlValue::Table(servers));
Ok(TomlValue::Table(root))
}
pub fn hooks_migration_description(
source_external_agent_dir: &Path,
target_hooks: &Path,
) -> io::Result<Option<String>> {
if hook_migration_event_names(source_external_agent_dir, target_hooks)?.is_empty() {
return Ok(None);
}
Ok(Some(format!(
"Migrate hooks from {} to {}",
source_external_agent_dir.display(),
target_hooks.display()
)))
}
pub fn hook_migration_event_names(
source_external_agent_dir: &Path,
target_hooks: &Path,
) -> io::Result<Vec<String>> {
let migration = hook_migration(source_external_agent_dir, target_hooks.parent())?;
Ok(migration.keys().cloned().collect())
}
pub fn import_hooks(source_external_agent_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
let Some(parent) = target_hooks.parent() else {
return Err(invalid_data_error("hooks target path has no parent"));
};
let migration = hook_migration(source_external_agent_dir, Some(parent))?;
if migration.is_empty() {
return Ok(false);
}
fs::create_dir_all(parent)?;
let mut wrote_active_hooks = false;
if is_missing_or_empty_text_file(target_hooks)? {
copy_hook_scripts(source_external_agent_dir, parent)?;
let mut payload = serde_json::Map::new();
payload.insert("hooks".to_string(), JsonValue::Object(migration));
let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload))
.map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?;
fs::write(target_hooks, format!("{rendered}\n"))?;
wrote_active_hooks = true;
}
Ok(wrote_active_hooks)
}
pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
Ok(missing_subagent_names(source_agents, target_agents)?.len())
}
pub fn missing_subagent_names(
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
let mut names = Vec::new();
for source_file in agent_source_files(source_agents)? {
let document = parse_document(&source_file)?;
let Some(metadata) = agent_metadata(&document) else {
continue;
};
let Some(target) = subagent_target_file(&source_file, target_agents) else {
continue;
};
if !target.exists() {
names.push(metadata.name);
}
}
Ok(names)
}
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
if !source_agents.is_dir() {
return Ok(0);
}
fs::create_dir_all(target_agents)?;
let mut imported = 0usize;
for source_file in agent_source_files(source_agents)? {
let Some(target) = subagent_target_file(&source_file, target_agents) else {
continue;
};
if target.exists() {
continue;
}
let document = parse_document(&source_file)?;
let Some(metadata) = agent_metadata(&document) else {
continue;
};
fs::write(&target, render_agent_toml(&document.body, &metadata)?)?;
imported += 1;
}
Ok(imported)
}
pub fn count_missing_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
Ok(missing_command_names(source_commands, target_skills)?.len())
}
pub fn missing_command_names(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
Ok(unique_supported_command_sources(source_commands)?
.into_iter()
.filter(|(_source_file, name)| !target_skills.join(name).exists())
.map(|(_source_file, name)| name)
.collect())
}
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
if !source_commands.is_dir() {
return Ok(0);
}
fs::create_dir_all(target_skills)?;
let mut imported = 0usize;
for (source_file, name) in unique_supported_command_sources(source_commands)? {
let document = parse_document(&source_file)?;
let target_dir = target_skills.join(&name);
if target_dir.exists() {
continue;
}
fs::create_dir_all(&target_dir)?;
let source_name = command_source_name(source_commands, &source_file);
let Some(description) = command_skill_description(&document, &source_name) else {
continue;
};
fs::write(
target_dir.join("SKILL.md"),
render_command_skill(&document.body, &name, &description, &source_name),
)?;
imported += 1;
}
Ok(imported)
}
fn read_external_mcp_servers(
source_root: &Path,
external_agent_home: Option<&Path>,
) -> io::Result<BTreeMap<String, JsonValue>> {
let mut servers = BTreeMap::new();
let project_config_file = external_agent_project_config_file();
for relative_path in [
EXTERNAL_AGENT_MCP_CONFIG_FILE.to_string(),
project_config_file.clone(),
] {
let source_file = source_root.join(&relative_path);
if !source_file.is_file() {
continue;
}
let raw = fs::read_to_string(&source_file)?;
let parsed: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
append_mcp_servers_from_value(&parsed, &mut servers, McpServerMerge::Overwrite);
if relative_path == project_config_file
&& let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object)
{
for (project_path, project_config) in projects {
if project_path_matches_source_root(project_path, source_root) {
append_mcp_servers_from_value(
project_config,
&mut servers,
McpServerMerge::Overwrite,
);
}
}
}
}
if let Some(external_agent_root) = external_agent_home.and_then(Path::parent)
&& external_agent_root != source_root
{
append_external_agent_project_mcp_servers(
&external_agent_root.join(external_agent_project_config_file()),
source_root,
&mut servers,
)?;
}
Ok(servers)
}
fn append_external_agent_project_mcp_servers(
source_file: &Path,
source_root: &Path,
servers: &mut BTreeMap<String, JsonValue>,
) -> io::Result<()> {
if !source_file.is_file() {
return Ok(());
}
let raw = fs::read_to_string(source_file)?;
let parsed: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object) else {
return Ok(());
};
for (project_path, project_config) in projects {
if project_path_matches_source_root(project_path, source_root) {
append_mcp_servers_from_value(
project_config,
servers,
McpServerMerge::PreserveExisting,
);
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum McpServerMerge {
Overwrite,
PreserveExisting,
}
fn append_mcp_servers_from_value(
value: &JsonValue,
servers: &mut BTreeMap<String, JsonValue>,
merge: McpServerMerge,
) {
let Some(mcp_servers) = value.get("mcpServers").and_then(JsonValue::as_object) else {
return;
};
for (server_name, server_config) in mcp_servers {
match merge {
McpServerMerge::Overwrite => {
servers.insert(server_name.clone(), server_config.clone());
}
McpServerMerge::PreserveExisting => {
servers
.entry(server_name.clone())
.or_insert_with(|| server_config.clone());
}
}
}
}
fn project_path_matches_source_root(project_path: &str, source_root: &Path) -> bool {
let project_path = Path::new(project_path);
if project_path == source_root {
return true;
}
let Ok(project_path) = project_path.canonicalize() else {
return false;
};
source_root
.canonicalize()
.is_ok_and(|source_root| source_root == project_path)
}
fn mcp_server_toml_table(
server_name: &str,
server_config: Option<&serde_json::Map<String, JsonValue>>,
enabled_servers: &[String],
disabled_servers: &BTreeSet<String>,
) -> Option<toml::map::Map<String, TomlValue>> {
let mut table = toml::map::Map::new();
let server_config = server_config?;
let transport_type = server_config.get("type").and_then(JsonValue::as_str);
if mcp_server_is_disabled(
server_name,
server_config,
enabled_servers,
disabled_servers,
) {
return None;
}
if let Some(command) = server_config.get("command").and_then(json_string) {
if !matches!(transport_type, None | Some("stdio")) {
return None;
}
if contains_env_placeholder(&command) {
return None;
}
table.insert("command".to_string(), TomlValue::String(command));
if let Some(args) = server_config.get("args") {
let args = json_string_vec(args);
if args.iter().any(|arg| contains_env_placeholder(arg)) {
return None;
}
let args = args.into_iter().map(TomlValue::String).collect::<Vec<_>>();
if !args.is_empty() {
table.insert("args".to_string(), TomlValue::Array(args));
}
}
if let Some(env) = server_config.get("env").and_then(JsonValue::as_object) {
append_env_config(&mut table, env)?;
}
} else if let Some(url) = server_config.get("url").and_then(json_string) {
if !matches!(
transport_type,
None | Some("http") | Some("streamable_http")
) {
return None;
}
if contains_env_placeholder(&url) {
return None;
}
table.insert("url".to_string(), TomlValue::String(url));
if let Some(headers) = server_config.get("headers").and_then(JsonValue::as_object) {
append_header_config(&mut table, headers)?;
}
} else {
return None;
}
Some(table)
}
fn mcp_server_is_disabled(
server_name: &str,
server_config: &serde_json::Map<String, JsonValue>,
enabled_servers: &[String],
disabled_servers: &BTreeSet<String>,
) -> bool {
server_config
.get("enabled")
.and_then(JsonValue::as_bool)
.is_some_and(|enabled| !enabled)
|| server_config
.get("disabled")
.and_then(JsonValue::as_bool)
.unwrap_or(false)
|| (!enabled_servers.is_empty() && !enabled_servers.iter().any(|name| name == server_name))
|| disabled_servers.contains(server_name)
}
fn append_header_config(
table: &mut toml::map::Map<String, TomlValue>,
headers: &serde_json::Map<String, JsonValue>,
) -> Option<()> {
let mut static_headers = toml::map::Map::new();
let mut env_headers = toml::map::Map::new();
for (key, value) in headers {
let header_value = json_string(value).unwrap_or_else(|| value.to_string());
if key.eq_ignore_ascii_case("authorization")
&& let Some(token_env) = header_value
.strip_prefix("Bearer ")
.and_then(parse_env_placeholder)
{
table.insert(
"bearer_token_env_var".to_string(),
TomlValue::String(token_env),
);
continue;
}
if let Some(env_var) = parse_env_placeholder(&header_value) {
env_headers.insert(key.clone(), TomlValue::String(env_var));
} else if contains_env_placeholder(&header_value) {
return None;
} else {
static_headers.insert(key.clone(), TomlValue::String(header_value));
}
}
if !static_headers.is_empty() {
table.insert("http_headers".to_string(), TomlValue::Table(static_headers));
}
if !env_headers.is_empty() {
table.insert(
"env_http_headers".to_string(),
TomlValue::Table(env_headers),
);
}
Some(())
}
fn append_env_config(
table: &mut toml::map::Map<String, TomlValue>,
env: &serde_json::Map<String, JsonValue>,
) -> Option<()> {
let mut static_env = toml::map::Map::new();
let mut env_vars = Vec::new();
for (key, value) in env {
let env_value = json_string(value).unwrap_or_else(|| value.to_string());
if parse_env_placeholder(&env_value).as_deref() == Some(key.as_str()) {
env_vars.push(TomlValue::String(key.clone()));
} else if contains_env_placeholder(&env_value) {
return None;
} else {
static_env.insert(key.clone(), TomlValue::String(env_value));
}
}
if !env_vars.is_empty() {
table.insert("env_vars".to_string(), TomlValue::Array(env_vars));
}
if !static_env.is_empty() {
table.insert("env".to_string(), TomlValue::Table(static_env));
}
Some(())
}
fn parse_env_placeholder(value: &str) -> Option<String> {
let inner = value.strip_prefix("${")?.strip_suffix('}')?;
let name = inner
.split_once(":-")
.map_or(inner, |(name, _default)| name);
let mut chars = name.chars();
let first = chars.next()?;
if !(first == '_' || first.is_ascii_alphabetic()) {
return None;
}
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
return None;
}
Some(name.to_string())
}
fn contains_env_placeholder(value: &str) -> bool {
value.contains("${")
}
fn hook_migration(
source_external_agent_dir: &Path,
target_config_dir: Option<&Path>,
) -> io::Result<serde_json::Map<String, JsonValue>> {
let mut settings_files = Vec::new();
let mut disable_all_hooks = None;
for settings_name in ["settings.json", "settings.local.json"] {
let settings_file = source_external_agent_dir.join(settings_name);
if !settings_file.is_file() {
continue;
}
let raw = fs::read_to_string(&settings_file)?;
let settings: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid hooks settings: {err}")))?;
if let Some(disabled) = settings.get("disableAllHooks").and_then(JsonValue::as_bool) {
disable_all_hooks = Some(disabled);
}
settings_files.push(settings);
}
if disable_all_hooks.unwrap_or(false) {
return Ok(serde_json::Map::new());
}
let mut migration = serde_json::Map::new();
for settings in settings_files {
append_convertible_hook_groups(&settings, &mut migration, target_config_dir);
}
Ok(migration)
}
fn append_convertible_hook_groups(
settings: &JsonValue,
hooks_payload: &mut serde_json::Map<String, JsonValue>,
target_config_dir: Option<&Path>,
) {
let Some(hooks_config) = settings.get("hooks").and_then(JsonValue::as_object) else {
return;
};
for event_name in HOOK_EVENT_NAMES {
let Some(groups) = hooks_config.get(event_name).and_then(JsonValue::as_array) else {
continue;
};
for group in groups {
let Some(group_object) = group.as_object() else {
continue;
};
if group_object.contains_key("if")
|| group_object
.keys()
.any(|key| !matches!(key.as_str(), "matcher" | "hooks"))
{
continue;
}
let mut hook_commands = Vec::new();
if let Some(hooks) = group_object.get("hooks").and_then(JsonValue::as_array) {
for hook in hooks {
let Some(hook_object) = hook.as_object() else {
continue;
};
let hook_type = hook_object
.get("type")
.and_then(JsonValue::as_str)
.unwrap_or("command");
if hook_type != "command" {
continue;
}
if hook_object.keys().any(|key| {
!matches!(
key.as_str(),
"type"
| "command"
| "timeout"
| "timeoutSec"
| "statusMessage"
| "async"
)
}) {
continue;
}
if hook_object
.get("async")
.and_then(JsonValue::as_bool)
.unwrap_or(false)
{
continue;
}
if ["asyncRewake", "shell", "once"]
.into_iter()
.any(|field| hook_object.contains_key(field))
{
continue;
}
let Some(command) = hook_object
.get("command")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|command| !command.is_empty())
else {
continue;
};
let mut command_payload = serde_json::Map::new();
command_payload
.insert("type".to_string(), JsonValue::String("command".to_string()));
command_payload.insert(
"command".to_string(),
JsonValue::String(rewrite_hook_command(command, target_config_dir)),
);
if let Some(timeout) = hook_object
.get("timeout")
.or_else(|| hook_object.get("timeoutSec"))
.and_then(json_u64)
{
command_payload.insert(
"timeout".to_string(),
JsonValue::Number(serde_json::Number::from(timeout)),
);
}
if let Some(status_message) =
hook_object.get("statusMessage").and_then(JsonValue::as_str)
{
command_payload.insert(
"statusMessage".to_string(),
JsonValue::String(rewrite_external_agent_terms(status_message)),
);
}
hook_commands.push(JsonValue::Object(command_payload));
}
}
if hook_commands.is_empty() {
continue;
}
let mut group_payload = serde_json::Map::new();
if HOOK_EVENT_NAMES_WITH_MATCHERS.contains(&event_name)
&& let Some(matcher) = group_object.get("matcher").and_then(JsonValue::as_str)
{
group_payload.insert(
"matcher".to_string(),
JsonValue::String(matcher.to_string()),
);
}
group_payload.insert("hooks".to_string(), JsonValue::Array(hook_commands));
if let Some(groups) = hooks_payload
.entry(event_name.to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()))
.as_array_mut()
{
groups.push(JsonValue::Object(group_payload));
}
}
}
}
fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> String {
let Some(target_config_dir) = target_config_dir else {
return command.to_string();
};
if looks_like_windows_hook_command(command) {
return command.to_string();
}
let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
let source_hooks_path = format!(
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/",
external_agent_config_dir()
);
let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir);
let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir);
replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir)
}
fn replace_quoted_hook_paths(
command: &str,
quote: char,
source_hooks_path: &str,
target_hooks_dir: &Path,
) -> String {
let mut rewritten = command.to_string();
let mut search_start = 0usize;
while let Some(relative_start) = rewritten[search_start..].find(quote) {
let start = search_start + relative_start;
let content_start = start + quote.len_utf8();
let Some(relative_end) = rewritten[content_start..].find(quote) else {
break;
};
let end = content_start + relative_end;
let content = &rewritten[content_start..end];
if let Some(source_hooks_start) = content.find(source_hooks_path) {
let suffix_start = source_hooks_start + source_hooks_path.len();
let suffix = &content[suffix_start..];
let Some(replacement) =
target_hook_path_replacement(target_hooks_dir, content, source_hooks_start, suffix)
else {
search_start = end + quote.len_utf8();
continue;
};
rewritten.replace_range(start..end + quote.len_utf8(), &replacement);
search_start = start + replacement.len();
} else {
search_start = end + quote.len_utf8();
}
}
rewritten
}
fn replace_unquoted_hook_paths(
command: &str,
source_hooks_path: &str,
target_hooks_dir: &Path,
) -> String {
let mut rewritten = command.to_string();
let mut search_start = 0usize;
while let Some(source_hooks_start) =
find_unquoted_source_hook_path(&rewritten, source_hooks_path, search_start)
{
let path_start = shell_path_start(&rewritten, source_hooks_start);
let path_end = shell_path_end(&rewritten, source_hooks_start + source_hooks_path.len());
if is_assignment_value_start(&rewritten, path_start) {
search_start = source_hooks_start + source_hooks_path.len();
continue;
}
let path = rewritten[path_start..path_end].to_string();
let suffix = rewritten[source_hooks_start + source_hooks_path.len()..path_end].to_string();
if let Some(replacement) = target_hook_path_replacement(
target_hooks_dir,
&path,
source_hooks_start - path_start,
&suffix,
) {
rewritten.replace_range(path_start..path_end, &replacement);
search_start = path_start + replacement.len();
} else {
search_start = source_hooks_start + source_hooks_path.len();
}
}
rewritten
}
fn find_unquoted_source_hook_path(
command: &str,
source_hooks_path: &str,
start: usize,
) -> Option<usize> {
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escaped = false;
for (offset, ch) in command[start..].char_indices() {
let index = start + offset;
if escaped {
escaped = false;
continue;
}
if !in_single_quote && ch == '\\' {
escaped = true;
continue;
}
match ch {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
_ if !in_single_quote
&& !in_double_quote
&& command[index..].starts_with(source_hooks_path) =>
{
return Some(index);
}
_ => {}
}
}
None
}
fn is_pure_shell_path_content(content: &str, source_hooks_start: usize) -> bool {
let prefix = &content[..source_hooks_start];
(prefix.is_empty() || prefix == "./" || prefix.ends_with('/'))
&& !prefix.chars().any(is_shell_path_boundary)
}
fn shell_path_start(command: &str, end: usize) -> usize {
command[..end]
.char_indices()
.filter_map(|(index, ch)| is_shell_path_boundary(ch).then_some(index + ch.len_utf8()))
.next_back()
.unwrap_or(0)
}
fn shell_path_end(command: &str, start: usize) -> usize {
let mut escaped = false;
for (offset, ch) in command[start..].char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if is_shell_path_boundary(ch) {
return start + offset;
}
}
command.len()
}
fn is_shell_path_boundary(ch: char) -> bool {
ch.is_whitespace() || matches!(ch, '=' | ';' | '|' | '&' | '<' | '>' | '(' | ')')
}
fn is_assignment_value_start(command: &str, path_start: usize) -> bool {
command[..path_start]
.chars()
.next_back()
.is_some_and(|ch| ch == '=')
}
fn target_hook_path_replacement(
target_hooks_dir: &Path,
path: &str,
source_hooks_start: usize,
suffix: &str,
) -> Option<String> {
if !is_pure_shell_path_content(path, source_hooks_start) || !is_static_hook_path_suffix(suffix)
{
return None;
}
Some(shell_single_quote(
target_hooks_dir.join(suffix).to_string_lossy().as_ref(),
))
}
fn is_static_hook_path_suffix(suffix: &str) -> bool {
!suffix.is_empty()
&& !suffix
.chars()
.any(|ch| matches!(ch, '\\' | '$' | '`' | '*' | '?' | '[' | '{' | '}'))
}
fn looks_like_windows_hook_command(command: &str) -> bool {
let source_hooks_backslash_path = format!(
r"{}\{EXTERNAL_AGENT_HOOKS_SUBDIR}\",
external_agent_config_dir()
);
let project_dir_env_var = external_agent_project_dir_env_var();
command.contains(&source_hooks_backslash_path)
|| command.contains(&format!("%{project_dir_env_var}%"))
|| command.contains(&format!("$env:{project_dir_env_var}"))
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn copy_hook_scripts(source_external_agent_dir: &Path, target_config_dir: &Path) -> io::Result<()> {
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
if !source_hooks.is_dir() {
return Ok(());
}
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
copy_dir_recursive_skip_existing(&source_hooks, &target_hooks)
}
fn copy_dir_recursive_skip_existing(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_skip_existing(&source_path, &target_path)?;
} else if file_type.is_file() && !target_path.exists() {
fs::copy(source_path, target_path)?;
}
}
Ok(())
}
fn agent_source_files(source_agents: &Path) -> io::Result<Vec<PathBuf>> {
if !source_agents.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in fs::read_dir(source_agents)? {
let entry = entry?;
let path = entry.path();
if !entry.file_type()?.is_file()
|| path.extension().and_then(|ext| ext.to_str()) != Some("md")
{
continue;
}
if path.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
continue;
}
files.push(path);
}
files.sort();
Ok(files)
}
fn subagent_target_file(source_file: &Path, target_agents: &Path) -> Option<PathBuf> {
Some(target_agents.join(format!("{}.toml", source_file.file_stem()?.to_str()?)))
}
fn command_source_files(source_commands: &Path) -> io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_markdown_files(source_commands, &mut files)?;
files.sort();
Ok(files)
}
fn unique_supported_command_sources(source_commands: &Path) -> io::Result<Vec<(PathBuf, String)>> {
let mut by_name = BTreeMap::<String, Vec<PathBuf>>::new();
for source_file in command_source_files(source_commands)? {
let document = parse_document(&source_file)?;
let Some(name) = command_skill_name_if_supported(source_commands, &source_file, &document)
else {
continue;
};
by_name.entry(name).or_default().push(source_file);
}
Ok(by_name
.into_iter()
.filter_map(|(name, source_files)| {
let [source_file] = source_files.as_slice() else {
return None;
};
Some((source_file.clone(), name))
})
.collect())
}
fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_markdown_files(&path, files)?;
} else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md")
{
files.push(path);
}
}
Ok(())
}
fn parse_document(source_file: &Path) -> io::Result<ParsedDocument> {
let content = fs::read_to_string(source_file)?;
Ok(parse_document_content(&content))
}
fn parse_document_content(content: &str) -> ParsedDocument {
let Some(rest) = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))
else {
return ParsedDocument {
frontmatter: BTreeMap::new(),
body: content.to_string(),
frontmatter_error: None,
};
};
let Some((end, body_start)) = frontmatter_end(rest) else {
return ParsedDocument {
frontmatter: BTreeMap::new(),
body: content.to_string(),
frontmatter_error: None,
};
};
let raw_frontmatter = &rest[..end];
let body = &rest[body_start..];
let (frontmatter, frontmatter_error) = parse_frontmatter(raw_frontmatter);
ParsedDocument {
frontmatter,
body: body.to_string(),
frontmatter_error,
}
}
fn frontmatter_end(rest: &str) -> Option<(usize, usize)> {
[
"\r\n---\r\n",
"\r\n---\n",
"\n---\r\n",
"\n---\n",
"\r\n---",
"\n---",
]
.into_iter()
.filter_map(|delimiter| rest.find(delimiter).map(|end| (end, end + delimiter.len())))
.min_by_key(|(end, _body_start)| *end)
}
fn parse_frontmatter(
raw_frontmatter: &str,
) -> (BTreeMap<String, FrontmatterValue>, Option<String>) {
let parsed: YamlValue = match serde_yaml::from_str(raw_frontmatter) {
Ok(parsed) => parsed,
Err(err) => return (BTreeMap::new(), Some(err.to_string())),
};
let Some(mapping) = parsed.as_mapping() else {
return (
BTreeMap::new(),
Some("frontmatter is not a YAML mapping".to_string()),
);
};
let mut frontmatter = BTreeMap::new();
for (key, value) in mapping {
let Some(key) = key.as_str().map(str::trim).filter(|key| !key.is_empty()) else {
continue;
};
frontmatter.insert(key.to_string(), frontmatter_value_from_yaml(value));
}
(frontmatter, None)
}
fn frontmatter_value_from_yaml(value: &YamlValue) -> FrontmatterValue {
match value {
YamlValue::String(value) => FrontmatterValue::Scalar(value.trim().to_string()),
YamlValue::Bool(value) => FrontmatterValue::Scalar(value.to_string()),
YamlValue::Number(value) => FrontmatterValue::Scalar(value.to_string()),
YamlValue::Null | YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => {
FrontmatterValue::Other
}
}
}
fn agent_metadata(document: &ParsedDocument) -> Option<AgentMetadata> {
if document.frontmatter_error.is_some() || document.body.trim().is_empty() {
return None;
}
let name = document
.frontmatter
.get("name")
.and_then(FrontmatterValue::as_scalar)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)?;
let description = document
.frontmatter
.get("description")
.and_then(FrontmatterValue::as_scalar)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)?;
Some(AgentMetadata {
name,
description,
permission_mode: frontmatter_string(&document.frontmatter, "permissionMode"),
effort: frontmatter_string(&document.frontmatter, "effort"),
})
}
fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result<String> {
let mut document = toml::map::Map::new();
document.insert("name".to_string(), TomlValue::String(metadata.name.clone()));
document.insert(
"description".to_string(),
TomlValue::String(rewrite_external_agent_terms(&metadata.description)),
);
if let Some(effort) = metadata.effort.as_ref()
&& let Some(effort) = map_agent_reasoning_effort(effort)
{
document.insert(
"model_reasoning_effort".to_string(),
TomlValue::String(effort),
);
}
if let Some(sandbox_mode) = metadata
.permission_mode
.as_deref()
.and_then(map_agent_permission_mode)
{
document.insert(
"sandbox_mode".to_string(),
TomlValue::String(sandbox_mode.to_string()),
);
}
document.insert(
"developer_instructions".to_string(),
TomlValue::String(render_agent_body(body)),
);
let serialized = toml::to_string_pretty(&TomlValue::Table(document))
.map_err(|err| invalid_data_error(format!("failed to serialize agent TOML: {err}")))?;
Ok(format!("{}\n", serialized.trim_end()))
}
fn render_agent_body(body: &str) -> String {
let body = rewrite_external_agent_terms(body.trim());
if body.is_empty() {
"No subagent instructions were found.".to_string()
} else {
body
}
}
fn command_skill_name(source_commands: &Path, source_file: &Path) -> String {
slugify_name(&format!(
"{COMMAND_SKILL_PREFIX}-{}",
command_source_name(source_commands, source_file)
))
}
fn command_skill_name_if_supported(
source_commands: &Path,
source_file: &Path,
document: &ParsedDocument,
) -> Option<String> {
if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
return None;
}
let source_name = command_source_name(source_commands, source_file);
let description = command_skill_description(document, &source_name)?;
let name = command_skill_name(source_commands, source_file);
if name.chars().count() > MAX_SKILL_NAME_LEN {
return None;
}
if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN {
return None;
}
if has_unsupported_command_template_features(&document.body) {
return None;
}
Some(name)
}
fn command_skill_description(document: &ParsedDocument, _source_name: &str) -> Option<String> {
document
.frontmatter
.get("description")
.and_then(FrontmatterValue::as_scalar)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
}
fn command_source_name(source_commands: &Path, source_file: &Path) -> String {
source_file
.strip_prefix(source_commands)
.unwrap_or(source_file)
.with_extension("")
.components()
.filter_map(|component| component.as_os_str().to_str())
.collect::<Vec<_>>()
.join("-")
}
fn render_command_skill(body: &str, name: &str, description: &str, source_name: &str) -> String {
let body = rewrite_external_agent_terms(body.trim());
let template_body = if body.is_empty() {
"No command template body was found.".to_string()
} else {
body
};
format!(
"---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n",
yaml_string(name),
yaml_string(&rewrite_external_agent_terms(description)),
)
}
fn has_unsupported_command_template_features(template: &str) -> bool {
template.contains("$ARGUMENTS")
|| contains_numbered_argument_placeholder(template)
|| (template.contains("{{") && template.contains("}}"))
|| template.contains("!`")
|| template.contains("! `")
|| template
.split_whitespace()
.any(|token| token.strip_prefix('@').is_some_and(|rest| !rest.is_empty()))
}
fn contains_numbered_argument_placeholder(template: &str) -> bool {
let bytes = template.as_bytes();
bytes
.windows(2)
.any(|window| window[0] == b'$' && window[1].is_ascii_digit())
}
fn frontmatter_string(
frontmatter: &BTreeMap<String, FrontmatterValue>,
key: &str,
) -> Option<String> {
frontmatter
.get(key)
.and_then(FrontmatterValue::as_scalar)
.map(ToOwned::to_owned)
}
fn map_agent_reasoning_effort(effort: &str) -> Option<String> {
let mapped = match effort {
"max" => "xhigh".to_string(),
_ => effort.to_string(),
};
matches!(
mapped.as_str(),
"none" | "minimal" | "low" | "medium" | "high" | "xhigh"
)
.then_some(mapped)
}
fn map_agent_permission_mode(permission_mode: &str) -> Option<&'static str> {
match permission_mode {
"acceptEdits" => Some("workspace-write"),
"readOnly" => Some("read-only"),
_ => None,
}
}
fn json_string_vec(value: &JsonValue) -> Vec<String> {
match value {
JsonValue::Array(values) => values.iter().filter_map(json_string).collect(),
_ => json_string(value).into_iter().collect(),
}
}
fn json_string(value: &JsonValue) -> Option<String> {
match value {
JsonValue::Null => None,
JsonValue::String(value) => Some(value.clone()),
JsonValue::Bool(value) => Some(value.to_string()),
JsonValue::Number(value) => Some(value.to_string()),
JsonValue::Array(_) | JsonValue::Object(_) => None,
}
}
fn json_u64(value: &JsonValue) -> Option<u64> {
if value.is_boolean() || value.is_null() {
return None;
}
value.as_u64().or_else(|| value.as_str()?.parse().ok())
}
fn yaml_string(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn slugify_name(value: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"migrated".to_string()
} else {
slug
}
}
impl FrontmatterValue {
fn as_scalar(&self) -> Option<&str> {
match self {
Self::Scalar(value) => Some(value),
Self::Other => None,
}
}
}
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 rewrite_external_agent_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
&external_agent_doc_file_name(),
"AGENTS.md",
);
for from in external_agent_term_variants() {
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 invalid_data_error(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn external_agent_config_dir() -> String {
format!(".{SOURCE_EXTERNAL_AGENT_NAME}")
}
fn external_agent_project_config_file() -> String {
format!(".{SOURCE_EXTERNAL_AGENT_NAME}.json")
}
fn external_agent_project_dir_env_var() -> String {
format!(
"{}_PROJECT_DIR",
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
)
}
fn external_agent_doc_file_name() -> String {
format!("{SOURCE_EXTERNAL_AGENT_NAME}.md")
}
fn external_agent_term_variants() -> [String; 5] {
[
format!("{SOURCE_EXTERNAL_AGENT_NAME} code"),
format!("{SOURCE_EXTERNAL_AGENT_NAME}-code"),
format!("{SOURCE_EXTERNAL_AGENT_NAME}_code"),
format!("{SOURCE_EXTERNAL_AGENT_NAME}code"),
SOURCE_EXTERNAL_AGENT_NAME.to_string(),
]
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn source_path(relative_path: &str) -> PathBuf {
Path::new("/repo")
.join(external_agent_config_dir())
.join(relative_path)
}
fn source_hook_command(script_name: &str) -> String {
format!(
"python3 {}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
external_agent_config_dir()
)
}
fn source_hook_command_with_project_dir(script_name: &str) -> String {
format!(
"python3 \"${}\"/{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
external_agent_project_dir_env_var(),
external_agent_config_dir()
)
}
fn migrated_hook_command(script_name: &str) -> String {
migrated_quoted_hook_command(script_name)
}
fn migrated_quoted_hook_command(script_name: &str) -> String {
let hook_path = Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join(script_name);
format!(
"python3 {}",
shell_single_quote(hook_path.to_string_lossy().as_ref())
)
}
#[test]
fn env_placeholder_accepts_defaults() {
assert_eq!(
parse_env_placeholder("${TOKEN:-fallback}"),
Some("TOKEN".to_string())
);
}
#[test]
fn mcp_migration_skips_placeholder_args() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{"mcpServers":{"db":{"command":"db-server","args":["${DATABASE_URL}"]}}}"#,
)
.expect("write mcp");
assert_eq!(
build_mcp_config_from_external(
root.path(),
/*external_agent_home*/ None,
/*settings*/ None,
)
.unwrap(),
TomlValue::Table(Default::default())
);
}
#[test]
fn mcp_migration_skips_unsupported_transports() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{
"mcpServers": {
"legacy-sse": {"type": "sse", "url": "https://example.invalid/sse"},
"vault": {
"url": "https://example.invalid/vault",
"headers": {"Authorization": "Bearer ${VAULT_TOKEN:-dev-token}"}
}
}
}"#,
)
.expect("write mcp");
assert_eq!(
build_mcp_config_from_external(
root.path(),
/*external_agent_home*/ None,
/*settings*/ None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.vault]
url = "https://example.invalid/vault"
bearer_token_env_var = "VAULT_TOKEN"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_reads_matching_project_entries_from_repo_external_project_config() {
let root = tempfile::TempDir::new().expect("tempdir");
let project = root.path().join("repo");
fs::create_dir_all(&project).expect("create repo");
let other = root.path().join("other");
fs::create_dir_all(&other).expect("create other");
fs::write(
project.join(external_agent_project_config_file()),
serde_json::json!({
"mcpServers": {
"top": {"command": "top-server"}
},
"projects": {
project.display().to_string(): {
"mcpServers": {
"repo": {"command": "repo-server"}
}
},
other.display().to_string(): {
"mcpServers": {
"other": {"command": "other-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
assert_eq!(
build_mcp_config_from_external(
&project, /*external_agent_home*/ None, /*settings*/ None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.repo]
command = "repo-server"
[mcp_servers.top]
command = "top-server"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_reads_matching_project_entries_from_home_external_project_config() {
let root = tempfile::TempDir::new().expect("tempdir");
let project = root.path().join("repo");
fs::create_dir_all(&project).expect("create repo");
let external_agent_home = root.path().join(external_agent_config_dir());
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
root.path().join(external_agent_project_config_file()),
serde_json::json!({
"projects": {
project.display().to_string(): {
"mcpServers": {
"repo": {"command": "repo-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
assert_eq!(
build_mcp_config_from_external(
&project,
Some(&external_agent_home),
/*settings*/ None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.repo]
command = "repo-server"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_preserves_repo_servers_over_home_project_entries() {
let root = tempfile::TempDir::new().expect("tempdir");
let project = root.path().join("repo");
fs::create_dir_all(&project).expect("create repo");
let external_agent_home = root.path().join(external_agent_config_dir());
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
project.join(EXTERNAL_AGENT_MCP_CONFIG_FILE),
serde_json::json!({
"mcpServers": {
"shared": {"command": "repo-server"}
}
})
.to_string(),
)
.expect("write repo mcp");
fs::write(
root.path().join(external_agent_project_config_file()),
serde_json::json!({
"projects": {
project.display().to_string(): {
"mcpServers": {
"home-only": {"command": "home-only-server"},
"shared": {"command": "home-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
assert_eq!(
build_mcp_config_from_external(
&project,
Some(&external_agent_home),
/*settings*/ None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.home-only]
command = "home-only-server"
[mcp_servers.shared]
command = "repo-server"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_skips_disabled_servers() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{
"mcpServers": {
"enabled": {"command": "enabled-server"},
"explicit-disabled": {"command": "disabled-server", "disabled": true},
"not-enabled": {"command": "not-enabled-server"}
}
}"#,
)
.expect("write mcp");
let settings = serde_json::json!({
"enabledMcpjsonServers": ["enabled"],
"disabledMcpjsonServers": ["explicit-disabled"]
});
assert_eq!(
build_mcp_config_from_external(
root.path(),
/*external_agent_home*/ None,
Some(&settings),
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.enabled]
command = "enabled-server"
"#
)
.unwrap()
);
}
#[test]
fn command_skill_names_include_nested_paths() {
let root = source_path("commands");
let file = source_path("commands/pr/review.md");
assert_eq!(command_skill_name(&root, &file), "source-command-pr-review");
}
#[test]
fn command_skill_names_must_fit_codex_skill_loader_limit() {
let root = source_path("commands");
let file = source_path("commands/this/is/a/deeply/nested/command/with/a/very/long/name.md");
let document = parse_document_content("---\ndescription: Review PR\n---\nReview\n");
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
}
#[test]
fn commands_with_provider_runtime_expansion_are_skipped() {
let root = source_path("commands");
let file = source_path("commands/deploy.md");
let document = parse_document_content(
"---\ndescription: Deploy\n---\nDeploy $ARGUMENTS from @release.yaml\n",
);
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
}
#[test]
fn commands_without_description_are_skipped() {
let root = source_path("commands");
let file = source_path("commands/README.md");
let document = parse_document_content("# Notes\n\nThis documents commands.\n");
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
}
#[test]
fn command_slug_collisions_are_skipped() {
let root = tempfile::TempDir::new().expect("tempdir");
let commands = root.path().join("commands");
fs::create_dir_all(&commands).expect("create commands");
fs::write(
commands.join("foo-bar.md"),
"---\ndescription: First\n---\nRun the first command.\n",
)
.expect("write first command");
fs::write(
commands.join("foo_bar.md"),
"---\ndescription: Second\n---\nRun the second command.\n",
)
.expect("write second command");
assert_eq!(
unique_supported_command_sources(&commands).unwrap(),
Vec::<(PathBuf, String)>::new()
);
}
#[test]
fn subagent_accepts_yaml_block_lists_by_ignoring_unsupported_fields() {
let document = parse_document_content(
"---\nname: cloud-incident\ndescription: Debug incidents\nskills:\n - runbook-reader\ntools:\n - Read\n - Bash\ndisallowedTools:\n - Write\n---\nInvestigate carefully.\n",
);
assert!(agent_metadata(&document).is_some());
}
#[test]
fn subagent_requires_minimum_codex_agent_fields() {
let missing_description =
parse_document_content("---\nname: incomplete\n---\nInvestigate carefully.\n");
let missing_body =
parse_document_content("---\nname: incomplete\ndescription: Missing body\n---\n");
assert!(agent_metadata(&missing_description).is_none());
assert!(agent_metadata(&missing_body).is_none());
}
#[test]
fn subagent_preserves_default_model_when_source_model_is_present() {
let document = parse_document_content(
"---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n",
);
let metadata = agent_metadata(&document).expect("metadata");
let rendered: TomlValue =
toml::from_str(&render_agent_toml(&document.body, &metadata).expect("render agent"))
.expect("parse rendered agent");
let expected: TomlValue = toml::from_str(
r#"
name = "reviewer"
description = "Review code"
model_reasoning_effort = "xhigh"
developer_instructions = """
Review carefully."""
"#,
)
.expect("parse expected agent");
assert_eq!(rendered, expected);
}
#[test]
fn subagent_target_preserves_dotted_file_stem() {
let target_agents = Path::new("/repo/.codex/agents");
let source_file = source_path("agents/security.audit.md");
assert_eq!(
subagent_target_file(&source_file, target_agents),
Some(PathBuf::from("/repo/.codex/agents/security.audit.toml"))
);
}
#[test]
fn frontmatter_accepts_crlf_delimiters() {
let document = parse_document_content(
"---\r\nname: reviewer\r\ndescription: Review code\r\n---\r\nReview carefully.\r\n",
);
assert_eq!(
(
document
.frontmatter
.get("name")
.and_then(FrontmatterValue::as_scalar),
document
.frontmatter
.get("description")
.and_then(FrontmatterValue::as_scalar),
document.body.as_str(),
),
(
Some("reviewer"),
Some("Review code"),
"Review carefully.\r\n"
)
);
}
#[test]
fn hook_migration_ignores_unsupported_handlers() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"if": "tool_input.command contains 'rm'",
"hooks": [{
"type": "command",
"command": source_hook_command("policy_gate.py")
}]
}, {
"matcher": "Edit",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": source_hook_command("policy_gate.py")
},
{
"type": "http",
"url": "https://example.invalid/hook"
}
]
}],
"PermissionRequest": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": source_hook_command("approve.py")
}]
}],
"SubagentStart": [{
"matcher": "worker",
"hooks": [{"type": "prompt", "prompt": "check"}]
}]
}
});
let mut migration = serde_json::Map::new();
append_convertible_hook_groups(&settings, &mut migration, Some(Path::new("/repo/.codex")));
assert_eq!(
migration,
serde_json::json!({
"PermissionRequest": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": migrated_hook_command("approve.py")
}]
}]
})
.as_object()
.cloned()
.expect("object")
);
}
#[test]
fn hook_migration_honors_disable_all_hooks() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join("settings.json"),
r#"{
"disableAllHooks": true,
"hooks": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{"type": "command", "command": "echo setup"}]
}]
}
}"#,
)
.expect("write settings");
assert_eq!(
hook_migration(root.path(), /*target_config_dir*/ None).unwrap(),
serde_json::Map::new()
);
}
#[test]
fn hook_migration_honors_settings_local_disable_override() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join("settings.json"),
r#"{
"disableAllHooks": true,
"hooks": {
"SessionStart": [{
"matcher": "project",
"hooks": [{"type": "command", "command": "echo project"}]
}]
}
}"#,
)
.expect("write project settings");
fs::write(
root.path().join("settings.local.json"),
r#"{
"disableAllHooks": false,
"hooks": {
"SessionStart": [{
"matcher": "local",
"hooks": [{"type": "command", "command": "echo local"}]
}]
}
}"#,
)
.expect("write local settings");
assert_eq!(
hook_migration(root.path(), /*target_config_dir*/ None).unwrap(),
serde_json::json!({
"SessionStart": [{
"matcher": "project",
"hooks": [{
"type": "command",
"command": "echo project"
}]
}, {
"matcher": "local",
"hooks": [{
"type": "command",
"command": "echo local"
}]
}]
})
.as_object()
.cloned()
.expect("object")
);
}
#[test]
fn hook_command_paths_rewrite_to_target_hook_dir() {
let project_dir_env_var = external_agent_project_dir_env_var();
let plugin_root_env_var = format!(
"{}_PLUGIN_ROOT",
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
);
let source_hooks_path = format!(
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}",
external_agent_config_dir()
);
assert_eq!(
rewrite_hook_command(
&source_hook_command_with_project_dir("check.py"),
Some(Path::new("/repo/.codex")),
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("\"${project_dir_env_var}\"/{source_hooks_path}/check-style.sh"),
Some(Path::new("/repo/.codex")),
),
shell_single_quote(
Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join("check-style.sh")
.to_string_lossy()
.as_ref()
)
);
assert_eq!(
rewrite_hook_command(
&source_hook_command("check.py"),
Some(Path::new("/repo/.codex")),
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 ./{source_hooks_path}/check.py"),
Some(Path::new("/repo/.codex")),
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/check.py'"),
Some(Path::new("/repo/.codex")),
),
migrated_quoted_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 \"${{{project_dir_env_var}}}/{source_hooks_path}/check.py\""),
Some(Path::new("/repo/.codex")),
),
migrated_quoted_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("bash -lc \"python3 {source_hooks_path}/check.py\""),
Some(Path::new("/repo/.codex")),
),
format!("bash -lc \"python3 {source_hooks_path}/check.py\"")
);
assert_eq!(
rewrite_hook_command(
&format!(
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
),
Some(Path::new("/repo/.codex")),
),
format!(
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
)
);
assert_eq!(
rewrite_hook_command(
&format!("python3 {source_hooks_path}/${{SCRIPT}}.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/${{SCRIPT}}.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 {source_hooks_path}/{{lint,fmt}}.sh"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/{{lint,fmt}}.sh")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 {source_hooks_path}/my\\ script.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/my\\ script.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 .{SOURCE_EXTERNAL_AGENT_NAME}\\hooks\\check.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 .{}\\hooks\\check.py", SOURCE_EXTERNAL_AGENT_NAME)
);
assert_eq!(
rewrite_hook_command(
&format!(
"python3 \"%{}%\\{}\\hooks\\check.py\"",
project_dir_env_var,
external_agent_config_dir()
),
Some(Path::new("/repo/.codex")),
),
format!(
"python3 \"%{}%\\{}\\hooks\\check.py\"",
project_dir_env_var,
external_agent_config_dir()
)
);
assert_eq!(
rewrite_hook_command(
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/my script.py'"),
Some(Path::new("/repo/.codex")),
),
migrated_quoted_hook_command("my script.py")
);
assert_eq!(
rewrite_hook_command(
&format!("/repo/{source_hooks_path}/check.py 2>/dev/null || true"),
Some(Path::new("/repo/.codex")),
),
format!(
"{} 2>/dev/null || true",
shell_single_quote(
Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join("check.py")
.to_string_lossy()
.as_ref()
)
)
);
let plugin_script_command = format!("${{{plugin_root_env_var}}}/scripts/format.sh");
assert_eq!(
rewrite_hook_command(&plugin_script_command, Some(Path::new("/repo/.codex")),),
plugin_script_command
);
}
#[test]
fn hook_script_copy_keeps_existing_target_scripts() {
let root = tempfile::TempDir::new().expect("tempdir");
let source_external_agent_dir = root.path().join(external_agent_config_dir());
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
let target_config_dir = root.path().join(".codex");
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
fs::create_dir_all(&source_hooks).expect("create source hooks");
fs::create_dir_all(&target_hooks).expect("create target hooks");
fs::write(source_hooks.join("check.py"), "new script").expect("write source hook");
fs::write(target_hooks.join("check.py"), "existing script").expect("write target hook");
copy_hook_scripts(&source_external_agent_dir, &target_config_dir).expect("copy hooks");
assert_eq!(
fs::read_to_string(target_hooks.join("check.py")).expect("read target hook"),
"existing script"
);
}
#[test]
fn hook_migration_drops_negative_timeouts() {
let settings = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "echo setup",
"timeout": -1
}]
}]
}
});
let mut migration = serde_json::Map::new();
append_convertible_hook_groups(&settings, &mut migration, /*target_config_dir*/ None);
assert_eq!(
migration,
serde_json::json!({
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "echo setup"
}]
}]
})
.as_object()
.cloned()
.expect("object")
);
}
}