diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5fb67c22b4..beee19672e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1239,6 +1239,10 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, + "command_attribution": { + "description": "Optional command attribution label for commit message co-author trailers.\n\nSet to an empty string to disable automatic command attribution.", + "type": "string" + }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" diff --git a/codex-rs/core/src/command_attribution.rs b/codex-rs/core/src/command_attribution.rs new file mode 100644 index 0000000000..cd97c6b4b1 --- /dev/null +++ b/codex-rs/core/src/command_attribution.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; + +use crate::config::Config; + +const DEFAULT_ATTRIBUTION_LABEL: &str = "Codex"; +const PREPARE_COMMIT_MSG_HOOK_NAME: &str = "prepare-commit-msg"; + +pub(crate) fn configure_git_hooks_env_for_config( + env: &mut HashMap, + config: &Config, +) { + configure_git_hooks_env( + env, + config.codex_home.as_path(), + config.command_attribution.as_deref(), + ); +} + +pub(crate) fn configure_git_hooks_env( + env: &mut HashMap, + codex_home: &Path, + config_attribution: Option<&str>, +) { + let Some(label) = resolve_attribution_label(config_attribution) else { + return; + }; + + let Ok(hooks_path) = ensure_codex_hook_scripts(codex_home, &label) else { + return; + }; + + set_git_runtime_config(env, "core.hooksPath", hooks_path.to_string_lossy().as_ref()); +} + +fn resolve_attribution_label(config_attribution: Option<&str>) -> Option { + match config_attribution { + Some(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + None => Some(DEFAULT_ATTRIBUTION_LABEL.to_string()), + } +} + +fn ensure_codex_hook_scripts(codex_home: &Path, label: &str) -> std::io::Result { + let hooks_dir = codex_home.join("hooks").join("command-attribution"); + fs::create_dir_all(&hooks_dir)?; + + let script = build_hook_script(label); + let hook_path = hooks_dir.join(PREPARE_COMMIT_MSG_HOOK_NAME); + let should_write = match fs::read_to_string(&hook_path) { + Ok(existing) => existing != script, + Err(_) => true, + }; + + if should_write { + fs::write(&hook_path, script.as_bytes())?; + } + + #[cfg(unix)] + { + let mut perms = fs::metadata(&hook_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&hook_path, perms)?; + } + + Ok(hooks_dir) +} + +fn build_hook_script(label: &str) -> String { + let escaped_label = label.replace('\'', "'\"'\"'"); + format!( + "#!/usr/bin/env bash\nset -euo pipefail\n\nmsg_file=\"${{1:-}}\"\nif [[ -n \"$msg_file\" && -f \"$msg_file\" ]]; then\n git interpret-trailers \\\n --in-place \\\n --if-exists doNothing \\\n --if-missing add \\\n --trailer 'Co-authored-by={escaped_label} ' \\\n \"$msg_file\" || true\nfi\n\nunset GIT_CONFIG_COUNT\nwhile IFS='=' read -r name _; do\n case \"$name\" in\n GIT_CONFIG_KEY_*|GIT_CONFIG_VALUE_*) unset \"$name\" ;;\n esac\ndone < <(env)\n\nexisting_hooks_path=\"$(git config --path core.hooksPath 2>/dev/null || true)\"\nif [[ -z \"$existing_hooks_path\" ]]; then\n git_dir=\"$(git rev-parse --git-common-dir 2>/dev/null || git rev-parse --git-dir 2>/dev/null || true)\"\n if [[ -n \"$git_dir\" ]]; then\n existing_hooks_path=\"$git_dir/hooks\"\n fi\nfi\n\nif [[ -n \"$existing_hooks_path\" ]]; then\n existing_hook=\"$existing_hooks_path/{PREPARE_COMMIT_MSG_HOOK_NAME}\"\n if [[ -x \"$existing_hook\" && \"$existing_hook\" != \"$0\" ]]; then\n \"$existing_hook\" \"$@\"\n fi\nfi\n" + ) +} + +fn set_git_runtime_config(env: &mut HashMap, key: &str, value: &str) { + let mut index = env + .get("GIT_CONFIG_COUNT") + .and_then(|count| count.parse::().ok()) + .unwrap_or(0); + + while env.contains_key(&format!("GIT_CONFIG_KEY_{index}")) + || env.contains_key(&format!("GIT_CONFIG_VALUE_{index}")) + { + index += 1; + } + + env.insert(format!("GIT_CONFIG_KEY_{index}"), key.to_string()); + env.insert(format!("GIT_CONFIG_VALUE_{index}"), value.to_string()); + env.insert("GIT_CONFIG_COUNT".to_string(), (index + 1).to_string()); +} + +#[cfg(test)] +mod tests { + use super::configure_git_hooks_env; + use super::configure_git_hooks_env_for_config; + use super::resolve_attribution_label; + use crate::config::test_config; + use std::collections::HashMap; + use tempfile::tempdir; + + #[test] + fn blank_attribution_disables_hook_env_injection() { + let tmp = tempdir().expect("create temp dir"); + let mut env = HashMap::new(); + + configure_git_hooks_env(&mut env, tmp.path(), Some("")); + + assert!(env.is_empty()); + } + + #[test] + fn default_attribution_injects_hooks_path() { + let tmp = tempdir().expect("create temp dir"); + let mut env = HashMap::new(); + + configure_git_hooks_env(&mut env, tmp.path(), None); + + assert_eq!(env.get("GIT_CONFIG_COUNT"), Some(&"1".to_string())); + assert_eq!( + env.get("GIT_CONFIG_KEY_0"), + Some(&"core.hooksPath".to_string()) + ); + assert!( + env.get("GIT_CONFIG_VALUE_0") + .expect("missing hooks path") + .contains("command-attribution") + ); + } + + #[test] + fn resolve_label_handles_default_custom_and_blank() { + assert_eq!(resolve_attribution_label(None), Some("Codex".to_string())); + assert_eq!( + resolve_attribution_label(Some("MyAgent")), + Some("MyAgent".to_string()) + ); + assert_eq!(resolve_attribution_label(Some(" ")), None); + } + + #[test] + fn helper_configures_env_from_config() { + let tmp = tempdir().expect("create temp dir"); + let mut config = test_config(); + config.codex_home = tmp.path().to_path_buf(); + config.command_attribution = Some("AgentX".to_string()); + let mut env = HashMap::new(); + + configure_git_hooks_env_for_config(&mut env, &config); + + assert_eq!(env.get("GIT_CONFIG_COUNT"), Some(&"1".to_string())); + assert_eq!( + env.get("GIT_CONFIG_KEY_0"), + Some(&"core.hooksPath".to_string()) + ); + assert!( + env.get("GIT_CONFIG_VALUE_0") + .expect("missing hooks path") + .contains("command-attribution") + ); + } +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 075d1ae70e..e261a96d45 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -187,6 +187,13 @@ pub struct Config { /// Compact prompt override. pub compact_prompt: Option, + /// Optional command attribution label for commit message co-author trailers. + /// + /// - `None`: use default attribution ("Codex") + /// - `Some("")` or whitespace-only: disable command attribution + /// - `Some("...")`: use the provided attribution text + pub command_attribution: Option, + /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -896,6 +903,11 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, + /// Optional command attribution label for commit message co-author trailers. + /// + /// Set to an empty string to disable automatic command attribution. + pub command_attribution: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] pub forced_chatgpt_workspace_id: Option, @@ -1620,6 +1632,8 @@ impl Config { } }); + let command_attribution = cfg.command_attribution; + // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -1736,6 +1750,7 @@ impl Config { personality, developer_instructions, compact_prompt, + command_attribution, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), @@ -4050,6 +4065,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + command_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4157,6 +4173,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + command_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4262,6 +4279,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + command_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4353,6 +4371,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + command_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 53ac2e0d88..fa88b5b99e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,6 +20,7 @@ pub use codex_thread::CodexThread; pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; +mod command_attribution; mod command_canonicalization; pub mod config; pub mod config_loader; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b9e2a97d6d..85f9dcb81d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -6,6 +6,7 @@ use codex_protocol::models::ShellToolCallParams; use std::sync::Arc; use crate::codex::TurnContext; +use crate::command_attribution::configure_git_hooks_env_for_config; use crate::exec::ExecParams; use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; @@ -250,6 +251,7 @@ impl ShellHandler { }; let mut exec_params = exec_params; + configure_git_hooks_env_for_config(&mut exec_params.env, turn.config.as_ref()); let dependency_env = session.dependency_env().await; if !dependency_env.is_empty() { exec_params.env.extend(dependency_env); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 251a4910ba..d781b67852 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -12,6 +12,7 @@ use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use crate::command_attribution::configure_git_hooks_env_for_config; use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; use crate::protocol::ExecCommandSource; @@ -517,10 +518,12 @@ impl UnifiedExecProcessManager { cwd: PathBuf, context: &UnifiedExecContext, ) -> Result { - let env = apply_unified_exec_env(create_env( + let mut env = create_env( &context.turn.shell_environment_policy, Some(context.session.conversation_id), - )); + ); + configure_git_hooks_env_for_config(&mut env, context.turn.config.as_ref()); + let env = apply_unified_exec_env(env); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); let exec_approval_requirement = context