[codex] Avoid duplicate hooks.json discovery with profiles (#26418)

## Summary

V2 profiles add both `config.toml` and `<profile>.config.toml` to the
config stack. Because both user layers resolve hook discovery to the
same Codex home, Codex loaded the same `hooks.json` twice. This
duplicated hook rows and caused each matching command to run twice.

Deduplicate JSON hook discovery by absolute config folder within each
effective config stack. TOML hooks remain layer-specific, and multi-cwd
`hooks/list` results remain independently resolved per cwd.

## Reproduction

1. Add `config.toml` and `work.config.toml` under `$CODEX_HOME`.
2. Add one command hook to `$CODEX_HOME/hooks.json`.
3. Run Codex with `--profile work`.
4. Trigger the hook.

Before this change, one declaration creates two handlers. Afterward, it
creates one.

Fixes #25645 and addresses the single-cwd duplication in #25437.

## Validation

- `cargo nextest run -p codex-hooks`
- `just fix -p codex-hooks`
- `just fmt`
- `just argument-comment-lint -p codex-hooks`
This commit is contained in:
Abhinav
2026-06-11 15:25:55 -07:00
committed by GitHub
parent 7516eb5c70
commit 0d8dee9427
2 changed files with 99 additions and 1 deletions
+91
View File
@@ -1134,6 +1134,97 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
assert_eq!(preview[1].source_path, config_path);
}
#[test]
fn profile_user_layers_load_shared_hooks_json_once() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let profile_path = AbsolutePathBuf::try_from(temp.path().join("work.config.toml"))
.expect("absolute profile path");
let hooks_json_path =
AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path");
fs::write(
hooks_json_path.as_path(),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/json-hook.py"
}
]
}
]
}
}"#,
)
.expect("write hooks.json");
let config_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_path,
profile: None,
},
TomlValue::Table(Default::default()),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: profile_path,
profile: Some("work".to_string()),
},
TomlValue::Table(Default::default()),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source_path, hooks_json_path);
let listed = crate::list_hooks(crate::HooksConfig {
feature_enabled: true,
bypass_hook_trust: true,
config_layer_stack: Some(config_layer_stack),
..Default::default()
});
assert!(listed.warnings.is_empty());
assert_eq!(listed.hooks.len(), 1);
assert_eq!(listed.hooks[0].source_path, hooks_json_path);
}
#[tokio::test]
async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() {
let temp = tempdir().expect("create temp dir");