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`
This commit is contained in:
alexsong-oai
2026-04-29 00:46:11 -07:00
committed by GitHub
parent 5597925155
commit d92c909ee4
3 changed files with 521 additions and 85 deletions
@@ -252,7 +252,7 @@ impl ExternalAgentConfigService {
|| 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 settings = effective_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"),
@@ -569,7 +569,7 @@ impl ExternalAgentConfigService {
) -> io::Result<Option<JsonValue>> {
if repo_root.is_some() && source_settings.is_none() {
let home_settings = self.external_agent_home.join("settings.json");
match read_external_settings(&home_settings) {
match effective_external_settings(&home_settings) {
Ok(settings) => Ok(settings),
Err(err) => {
tracing::warn!(
@@ -636,7 +636,7 @@ impl ExternalAgentConfigService {
|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)?
let import_sources = effective_external_settings(&source_settings)?
.map(|settings| collect_marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
@@ -697,9 +697,11 @@ impl ExternalAgentConfigService {
|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 import_source =
effective_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);
@@ -767,13 +769,9 @@ impl ExternalAgentConfigService {
self.codex_home.join("config.toml"),
)
};
if !source_settings.is_file() {
let Some(settings) = effective_external_settings(&source_settings)? else {
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(());
@@ -822,7 +820,7 @@ impl ExternalAgentConfigService {
};
let settings = self.mcp_settings(
repo_root.as_deref(),
read_external_settings(&source_settings)?,
effective_external_settings(&source_settings)?,
)?;
let migrated = build_mcp_config_from_external(
self.source_root(repo_root.as_deref()).as_path(),
@@ -999,6 +997,43 @@ fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
Ok(Some(settings))
}
fn effective_external_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {
let mut effective = 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 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)
}
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,
source_root: &Path,
@@ -707,6 +707,68 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
);
}
#[tokio::test]
async fn import_home_config_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"project","PROJECT_ONLY":"yes"},"sandbox":{"enabled":false}}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
r#"{"env":{"FOO":"local","LOCAL_ONLY":true},"sandbox":{"enabled":true}}"#,
)
.expect("write local settings");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n"
);
}
#[tokio::test]
async fn import_home_config_ignores_invalid_local_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"project"},"sandbox":{"enabled":false}}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
"{invalid json",
)
.expect("write local settings");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"project\"\n"
);
}
#[tokio::test]
async fn import_home_skips_empty_config_migration() {
let (_root, external_agent_home, codex_home) = fixture_paths();
@@ -1144,6 +1206,67 @@ command = "allowed-server"
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"project-disabled": {"command": "project-disabled-server"},
"local-disabled": {"command": "local-disabled-server"},
"local-enabled": {"command": "local-enabled-server"}
}
}"#,
)
.expect("write mcp");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledMcpjsonServers": ["project-disabled", "local-disabled"],
"disabledMcpjsonServers": ["project-disabled"]
}"#,
)
.expect("write project settings");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.local.json"),
r#"{
"enabledMcpjsonServers": ["local-enabled", "local-disabled"],
"disabledMcpjsonServers": ["local-disabled"]
}"#,
)
.expect("write local settings");
service_for_paths(external_agent_home, root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await
.expect("import");
let config: TomlValue = toml::from_str(
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
[mcp_servers.local-enabled]
command = "local-enabled-server"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missing() {
let root = TempDir::new().expect("create tempdir");
@@ -1286,6 +1409,64 @@ async fn detect_home_lists_enabled_plugins_from_settings() {
);
}
#[tokio::test]
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"legacy@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": false,
"deployer@acme-tools": true
}
}"#,
)
.expect("write local settings");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string(), "legacy".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_repo_skips_plugins_that_are_already_configured_in_codex() {
let root = TempDir::new().expect("create tempdir");