mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## What changed - Add the `SessionEnd` hook event to hook configuration, protocol schemas, analytics, and the hooks browser. - Run matching hooks for root threads during shutdown, including app-server archive, delete, idle unload, and graceful shutdown. Flush the transcript first and provide the session ID, transcript path, working directory, and `reason: "other"`. - Keep teardown bounded: hook output is advisory, the default timeout is one second, configured timeouts are capped at three seconds, and async hooks run synchronously with a warning. ## Testing - Cover transcript availability, matcher selection, timeout normalization, ignored control output, subagent exclusion, archive/delete ordering, and graceful shutdown of multiple loaded threads. GitOrigin-RevId: 5d19a658677a137caf836ed5042dcb43f5eb6d1a
789 lines
22 KiB
Rust
789 lines
22 KiB
Rust
use super::hooks_cla::append_convertible_hook_groups_cla;
|
|
use super::hooks_cla::hook_migration_cla;
|
|
use super::hooks_cla::rewrite_hook_command_cla;
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use toml::Value as TomlValue;
|
|
|
|
const TEST_REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
|
|
"CLAUDE.md",
|
|
&[
|
|
"claude code",
|
|
"claude-code",
|
|
"claude_code",
|
|
"claudecode",
|
|
"claude",
|
|
],
|
|
);
|
|
|
|
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_prefers_command_transport_for_mixed_server_config() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
fs::write(
|
|
root.path().join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"mixedTransport": {
|
|
"command": "mcp-remote-proxy",
|
|
"args": [
|
|
"https://example.com/mixed-transport",
|
|
"--transport",
|
|
"http"
|
|
],
|
|
"url": "https://example.com/mixed-transport"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.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.mixedTransport]
|
|
command = "mcp-remote-proxy"
|
|
args = [
|
|
"https://example.com/mixed-transport",
|
|
"--transport",
|
|
"http",
|
|
]
|
|
"#
|
|
)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[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 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, TEST_REWRITE_PROFILE).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")
|
|
}]
|
|
}],
|
|
"SessionEnd": [{
|
|
"matcher": "clear",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": source_hook_command("cleanup.py")
|
|
}]
|
|
}],
|
|
"SubagentStart": [{
|
|
"matcher": "worker",
|
|
"hooks": [{"type": "prompt", "prompt": "check"}]
|
|
}]
|
|
}
|
|
});
|
|
let mut migration = serde_json::Map::new();
|
|
append_convertible_hook_groups_cla(
|
|
&settings,
|
|
&mut migration,
|
|
Some(Path::new("/repo/.codex")),
|
|
TEST_REWRITE_PROFILE,
|
|
);
|
|
|
|
assert_eq!(
|
|
migration,
|
|
serde_json::json!({
|
|
"PermissionRequest": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": migrated_hook_command("approve.py")
|
|
}]
|
|
}],
|
|
"SessionEnd": [{
|
|
"matcher": "clear",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": migrated_hook_command("cleanup.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_cla(
|
|
root.path(),
|
|
/*target_config_dir*/ None,
|
|
TEST_REWRITE_PROFILE,
|
|
)
|
|
.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_cla(
|
|
root.path(),
|
|
/*target_config_dir*/ None,
|
|
TEST_REWRITE_PROFILE,
|
|
)
|
|
.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_cla(
|
|
&source_hook_command_with_project_dir("check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command_cla(
|
|
&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_cla(
|
|
&source_hook_command("check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command_cla(
|
|
&format!("python3 ./{source_hooks_path}/check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&format!("python3 {source_hooks_path}/${{SCRIPT}}.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!("python3 {source_hooks_path}/${{SCRIPT}}.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&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_cla(
|
|
&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_cla(&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_cla(
|
|
&settings,
|
|
&mut migration,
|
|
/*target_config_dir*/ None,
|
|
TEST_REWRITE_PROFILE,
|
|
);
|
|
|
|
assert_eq!(
|
|
migration,
|
|
serde_json::json!({
|
|
"SessionStart": [{
|
|
"matcher": "startup",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo setup"
|
|
}]
|
|
}]
|
|
})
|
|
.as_object()
|
|
.cloned()
|
|
.expect("object")
|
|
);
|
|
}
|