mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user