Compare commits

...

5 Commits

Author SHA1 Message Date
Gabriel Cohen
a49e94a38d Skip duplicate snapshot git config for attribution
Avoid appending Codex's runtime git config when the shell snapshot already defines the same key, preserving user hook setup instead of competing with it.

Add regression coverage for a preexisting snapshot core.hooksPath.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 22:18:37 -07:00
Gabriel Cohen
71ed2fdb39 Preserve snapshot git runtime config
Co-authored-by: Codex <noreply@openai.com>
2026-03-19 15:01:57 -07:00
Gabriel Cohen
3483340191 Add codex exec commit attribution tests
Co-authored-by: Codex <noreply@openai.com>
2026-03-13 15:32:58 -07:00
Gabriel Cohen
dbb891a65f Preserve user commit hooks during attribution
Co-authored-by: Codex <noreply@openai.com>
2026-03-13 15:00:47 -07:00
Gabriel Cohen
d88711f189 Use git hooks for commit attribution
Co-authored-by: Codex <noreply@openai.com>
2026-03-13 14:26:22 -07:00
16 changed files with 877 additions and 54 deletions

View File

@@ -1778,6 +1778,7 @@
"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."
},
"commit_attribution": {
"default": null,
"description": "Optional commit attribution text for commit message co-author trailers.\n\nSet to an empty string to disable automatic commit attribution.",
"type": "string"
},

View File

@@ -17,7 +17,6 @@ use crate::analytics_client::AppInvocation;
use crate::analytics_client::InvocationType;
use crate::analytics_client::build_track_events_context;
use crate::apps::render_apps_section;
use crate::commit_attribution::commit_message_trailer_instruction;
use crate::compact;
use crate::compact::InitialContextInjection;
use crate::compact::run_inline_auto_compact_task;
@@ -3433,13 +3432,6 @@ impl Session {
if turn_context.apps_enabled() {
developer_sections.push(render_apps_section());
}
if turn_context.features.enabled(Feature::CodexGitCommit)
&& let Some(commit_message_instruction) = commit_message_trailer_instruction(
turn_context.config.commit_attribution.as_deref(),
)
{
developer_sections.push(commit_message_instruction);
}
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
contextual_user_sections.push(
UserInstructions {

View File

@@ -1,17 +1,61 @@
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
use std::collections::HashMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option<String> {
let value = resolve_attribution_value(config_attribution)?;
Some(format!("Co-authored-by: {value}"))
use crate::config::Config;
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
const PREPARE_COMMIT_MSG_HOOK_NAME: &str = "prepare-commit-msg";
const COMMIT_HOOK_NAMES: &[&str] = &[
"applypatch-msg",
"commit-msg",
"post-commit",
"pre-applypatch",
"pre-commit",
"pre-merge-commit",
PREPARE_COMMIT_MSG_HOOK_NAME,
];
pub(crate) fn configure_git_hooks_env_for_config(
env: &mut HashMap<String, String>,
config: &Config,
) -> Vec<(String, String)> {
configure_git_hooks_env(
env,
config.codex_home.as_path(),
config.commit_attribution.as_deref(),
)
}
pub(crate) fn commit_message_trailer_instruction(
pub(crate) fn configure_git_hooks_env(
env: &mut HashMap<String, String>,
codex_home: &Path,
config_attribution: Option<&str>,
) -> Option<String> {
let trailer = build_commit_message_trailer(config_attribution)?;
Some(format!(
"When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block."
))
) -> Vec<(String, String)> {
let Some((key, value)) = git_hooks_runtime_config(codex_home, config_attribution) else {
return Vec::new();
};
set_git_runtime_config(env, &key, &value);
vec![(key, value)]
}
#[cfg(test)]
pub(crate) fn injected_git_config_env(env: &HashMap<String, String>) -> Vec<(String, String)> {
let mut pairs = env
.iter()
.filter(|(key, _)| {
*key == "GIT_CONFIG_COUNT"
|| key.starts_with("GIT_CONFIG_KEY_")
|| key.starts_with("GIT_CONFIG_VALUE_")
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Vec<_>>();
pairs.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
pairs
}
fn resolve_attribution_value(config_attribution: Option<&str>) -> Option<String> {
@@ -28,6 +72,111 @@ fn resolve_attribution_value(config_attribution: Option<&str>) -> Option<String>
}
}
fn git_hooks_runtime_config(
codex_home: &Path,
config_attribution: Option<&str>,
) -> Option<(String, String)> {
let value = resolve_attribution_value(config_attribution)?;
let hooks_path = ensure_codex_hook_scripts(codex_home, &value).ok()?;
Some((
"core.hooksPath".to_string(),
hooks_path.to_string_lossy().into_owned(),
))
}
fn ensure_codex_hook_scripts(codex_home: &Path, value: &str) -> std::io::Result<PathBuf> {
let hooks_dir = codex_home.join("hooks").join("commit-attribution");
fs::create_dir_all(&hooks_dir)?;
for hook_name in COMMIT_HOOK_NAMES {
let script = build_hook_script(hook_name, value);
let hook_path = hooks_dir.join(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(hook_name: &str, value: &str) -> String {
let escaped_value = value.replace('\'', "'\"'\"'");
let prepare_commit_msg_body = if hook_name == PREPARE_COMMIT_MSG_HOOK_NAME {
format!(
r#"
msg_file="${{1:-}}"
if [[ -n "$msg_file" && -f "$msg_file" ]]; then
git interpret-trailers \
--in-place \
--if-exists doNothing \
--if-missing add \
--trailer 'Co-authored-by={escaped_value}' \
"$msg_file" || true
fi
"#
)
} else {
String::new()
};
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
unset GIT_CONFIG_COUNT
while IFS='=' read -r name _; do
case "$name" in
GIT_CONFIG_KEY_*|GIT_CONFIG_VALUE_*) unset "$name" ;;
esac
done < <(env)
existing_hooks_path="$(git config --path core.hooksPath 2>/dev/null || true)"
if [[ -z "$existing_hooks_path" ]]; then
git_dir="$(git rev-parse --git-common-dir 2>/dev/null || git rev-parse --git-dir 2>/dev/null || true)"
if [[ -n "$git_dir" ]]; then
existing_hooks_path="$git_dir/hooks"
fi
fi
if [[ -n "$existing_hooks_path" ]]; then
existing_hook="$existing_hooks_path/{hook_name}"
if [[ -x "$existing_hook" && "$existing_hook" != "$0" ]]; then
"$existing_hook" "$@"
fi
fi
{prepare_commit_msg_body}"#
)
}
fn set_git_runtime_config(env: &mut HashMap<String, String>, key: &str, value: &str) {
let mut index = env
.get("GIT_CONFIG_COUNT")
.and_then(|count| count.parse::<usize>().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)]
#[path = "commit_attribution_tests.rs"]
mod tests;

View File

@@ -1,19 +1,68 @@
use super::build_commit_message_trailer;
use super::commit_message_trailer_instruction;
use super::configure_git_hooks_env;
use super::configure_git_hooks_env_for_config;
use super::injected_git_config_env;
use super::resolve_attribution_value;
use crate::config::test_config;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::fs;
use tempfile::tempdir;
#[test]
fn blank_attribution_disables_trailer_prompt() {
assert_eq!(build_commit_message_trailer(Some("")), None);
assert_eq!(commit_message_trailer_instruction(Some(" ")), None);
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_uses_codex_trailer() {
let tmp = tempdir().expect("create temp dir");
let mut env = HashMap::new();
configure_git_hooks_env(&mut env, tmp.path(), None);
let hook_path = tmp
.path()
.join("hooks")
.join("commit-attribution")
.join("prepare-commit-msg");
let script = fs::read_to_string(hook_path).expect("read generated hook");
assert_eq!(env.get("GIT_CONFIG_COUNT"), Some(&"1".to_string()));
assert_eq!(
build_commit_message_trailer(None).as_deref(),
Some("Co-authored-by: Codex <noreply@openai.com>")
env.get("GIT_CONFIG_KEY_0"),
Some(&"core.hooksPath".to_string())
);
assert!(script.contains("Co-authored-by=Codex <noreply@openai.com>"));
assert!(script.contains("existing_hook=\"$existing_hooks_path/prepare-commit-msg\""));
}
#[test]
fn generated_hooks_preserve_other_commit_hooks() {
let tmp = tempdir().expect("create temp dir");
let mut env = HashMap::new();
configure_git_hooks_env(&mut env, tmp.path(), None);
let hooks_dir = tmp.path().join("hooks").join("commit-attribution");
for hook_name in [
"applypatch-msg",
"commit-msg",
"post-commit",
"pre-applypatch",
"pre-commit",
"pre-merge-commit",
"prepare-commit-msg",
] {
let script = fs::read_to_string(hooks_dir.join(hook_name)).expect("read generated hook");
assert!(script.contains(&format!(
"existing_hook=\"$existing_hooks_path/{hook_name}\""
)));
}
}
#[test]
@@ -34,10 +83,71 @@ fn resolve_value_handles_default_custom_and_blank() {
}
#[test]
fn instruction_mentions_trailer_and_omits_generated_with() {
let instruction = commit_message_trailer_instruction(Some("AgentX <agent@example.com>"))
.expect("instruction expected");
assert!(instruction.contains("Co-authored-by: AgentX <agent@example.com>"));
assert!(instruction.contains("exactly once"));
assert!(!instruction.contains("Generated-with"));
fn custom_attribution_writes_custom_hook_script() {
let tmp = tempdir().expect("create temp dir");
let mut env = HashMap::new();
configure_git_hooks_env(&mut env, tmp.path(), Some("AgentX <agent@example.com>"));
let hook_path = tmp
.path()
.join("hooks")
.join("commit-attribution")
.join("prepare-commit-msg");
let script = fs::read_to_string(hook_path).expect("read generated hook");
assert!(script.contains("Co-authored-by=AgentX <agent@example.com>"));
assert!(script.contains("existing_hook=\"$existing_hooks_path/prepare-commit-msg\""));
assert!(script.contains("\"$existing_hook\" \"$@\""));
let pre_commit = fs::read_to_string(
tmp.path()
.join("hooks")
.join("commit-attribution")
.join("pre-commit"),
)
.expect("read generated pre-commit hook");
assert!(!pre_commit.contains("Co-authored-by=AgentX <agent@example.com>"));
}
#[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.commit_attribution = Some("AgentX <agent@example.com>".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("commit-attribution")
);
}
#[test]
fn injected_git_config_env_returns_sorted_runtime_overrides() {
let env = HashMap::from([
("IGNORED".to_string(), "value".to_string()),
("GIT_CONFIG_VALUE_1".to_string(), "beta".to_string()),
("GIT_CONFIG_KEY_0".to_string(), "core.hooksPath".to_string()),
("GIT_CONFIG_COUNT".to_string(), "2".to_string()),
("GIT_CONFIG_VALUE_0".to_string(), "/tmp/hooks".to_string()),
]);
assert_eq!(
injected_git_config_env(&env),
vec![
("GIT_CONFIG_COUNT".to_string(), "2".to_string()),
("GIT_CONFIG_KEY_0".to_string(), "core.hooksPath".to_string()),
("GIT_CONFIG_VALUE_0".to_string(), "/tmp/hooks".to_string()),
("GIT_CONFIG_VALUE_1".to_string(), "beta".to_string()),
]
);
}

View File

@@ -158,6 +158,18 @@ consolidation_model = "gpt-5"
);
}
#[test]
fn config_toml_ignores_malformed_commit_attribution() {
let cfg = toml::from_str::<ConfigToml>(
r#"
commit_attribution = true
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(cfg.commit_attribution, None);
}
#[test]
fn parses_bundled_skills_config() {
let cfg: ConfigToml = toml::from_str(

View File

@@ -84,6 +84,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::IgnoredAny;
use similar::DiffableStr;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -1103,6 +1104,7 @@ pub struct ConfigToml {
/// Optional commit attribution text for commit message co-author trailers.
///
/// Set to an empty string to disable automatic commit attribution.
#[serde(default, deserialize_with = "deserialize_optional_commit_attribution")]
pub commit_attribution: Option<String>,
/// When set, restricts ChatGPT login to a specific workspace identifier.
@@ -1411,6 +1413,29 @@ enum WebSearchToolConfigInput {
Config(WebSearchToolConfig),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum CommitAttributionInput {
String(String),
// Ignore malformed values so a bad local override does not prevent Codex from starting.
Ignored(IgnoredAny),
}
fn deserialize_optional_commit_attribution<'de, D>(
deserializer: D,
) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<CommitAttributionInput>::deserialize(deserializer)?;
Ok(match value {
None => None,
Some(CommitAttributionInput::String(value)) => Some(value),
Some(CommitAttributionInput::Ignored(_)) => None,
})
}
fn deserialize_optional_web_search_tool_config<'de, D>(
deserializer: D,
) -> Result<Option<WebSearchToolConfig>, D::Error>

View File

@@ -124,7 +124,7 @@ pub enum Feature {
RemoteModels,
/// Experimental shell snapshotting.
ShellSnapshot,
/// Enable git commit attribution guidance via model instructions.
/// Enable git commit attribution via runtime git hook injection.
CodexGitCommit,
/// Enable runtime metrics snapshots via a manual reader.
RuntimeMetrics,

View File

@@ -130,6 +130,7 @@ pub(crate) async fn execute_user_shell_command(
session_shell.as_ref(),
turn_context.cwd.as_path(),
&turn_context.shell_environment_policy.r#set,
&[],
);
let call_id = Uuid::new_v4().to_string();

View File

@@ -5,6 +5,7 @@ use codex_protocol::models::ShellToolCallParams;
use std::sync::Arc;
use crate::codex::TurnContext;
use crate::commit_attribution::configure_git_hooks_env_for_config;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
@@ -324,6 +325,11 @@ impl ShellHandler {
} = args;
let mut exec_params = exec_params;
let runtime_git_config_overrides = if session.features().enabled(Feature::CodexGitCommit) {
configure_git_hooks_env_for_config(&mut exec_params.env, turn.config.as_ref())
} else {
Vec::new()
};
let dependency_env = session.dependency_env().await;
if !dependency_env.is_empty() {
exec_params.env.extend(dependency_env.clone());
@@ -335,7 +341,6 @@ impl ShellHandler {
explicit_env_overrides.insert(key.clone(), value.clone());
}
}
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
@@ -435,6 +440,7 @@ impl ShellHandler {
timeout_ms: exec_params.expiration.timeout_ms(),
env: exec_params.env.clone(),
explicit_env_overrides,
runtime_git_config_overrides,
network: exec_params.network.clone(),
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,

View File

@@ -70,6 +70,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
session_shell: &Shell,
cwd: &Path,
explicit_env_overrides: &HashMap<String, String>,
runtime_git_config_overrides: &[(String, String)],
) -> Vec<String> {
if cfg!(windows) {
return command.to_vec();
@@ -113,13 +114,14 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
.collect::<String>();
let (override_captures, override_exports) = build_override_exports(explicit_env_overrides);
let rewritten_script = if override_exports.is_empty() {
let runtime_git_config_appends = build_runtime_git_config_appends(runtime_git_config_overrides);
let rewritten_script = if override_exports.is_empty() && runtime_git_config_appends.is_empty() {
format!(
"if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
)
} else {
format!(
"{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
"{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}{runtime_git_config_appends}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
)
};
@@ -161,6 +163,46 @@ fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (
(captures, restores)
}
fn build_runtime_git_config_appends(runtime_git_config_overrides: &[(String, String)]) -> String {
if runtime_git_config_overrides.is_empty() {
return String::new();
}
let mut script = String::from(
"\ncase \"${GIT_CONFIG_COUNT:-}\" in\n ''|*[!0-9]*) __CODEX_GIT_CONFIG_INDEX=0 ;;\n *) __CODEX_GIT_CONFIG_INDEX=\"$GIT_CONFIG_COUNT\" ;;\nesac\n",
);
script.push_str(
"__codex_has_runtime_git_config_key() {\n\
__CODEX_GIT_CONFIG_LOOKUP_KEY=\"$1\"\n\
__CODEX_GIT_CONFIG_SCAN_INDEX=0\n\
while [ \"$__CODEX_GIT_CONFIG_SCAN_INDEX\" -lt \"$__CODEX_GIT_CONFIG_INDEX\" ]; do\n\
eval \"__CODEX_GIT_CONFIG_EXISTING_KEY=\\${GIT_CONFIG_KEY_${__CODEX_GIT_CONFIG_SCAN_INDEX}-}\"\n\
if [ \"$__CODEX_GIT_CONFIG_EXISTING_KEY\" = \"$__CODEX_GIT_CONFIG_LOOKUP_KEY\" ]; then\n\
return 0\n\
fi\n\
__CODEX_GIT_CONFIG_SCAN_INDEX=$((__CODEX_GIT_CONFIG_SCAN_INDEX + 1))\n\
done\n\
return 1\n\
}\n",
);
for (key, value) in runtime_git_config_overrides {
let key = shell_single_quote(key);
let value = shell_single_quote(value);
script.push_str(&format!(
"if ! __codex_has_runtime_git_config_key '{key}'; then\n\
eval \"export GIT_CONFIG_KEY_${{__CODEX_GIT_CONFIG_INDEX}}='{key}'\"\n\
eval \"export GIT_CONFIG_VALUE_${{__CODEX_GIT_CONFIG_INDEX}}='{value}'\"\n\
__CODEX_GIT_CONFIG_INDEX=$((__CODEX_GIT_CONFIG_INDEX + 1))\n\
fi\n"
));
}
script.push_str("export GIT_CONFIG_COUNT=\"$__CODEX_GIT_CONFIG_INDEX\"\n");
script
}
fn is_valid_shell_variable_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {

View File

@@ -42,8 +42,13 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() {
"echo hello".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&[],
);
assert_eq!(rewritten[0], "/bin/zsh");
assert_eq!(rewritten[1], "-c");
@@ -68,8 +73,13 @@ fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() {
"echo 'hello'".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&[],
);
assert!(rewritten[2].contains(r#"exec '/bin/bash' -c 'echo '"'"'hello'"'"''"#));
}
@@ -91,8 +101,13 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() {
"echo hello".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&[],
);
assert_eq!(rewritten[0], "/bin/bash");
assert_eq!(rewritten[1], "-c");
@@ -117,8 +132,13 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() {
"echo hello".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&[],
);
assert_eq!(rewritten[0], "/bin/sh");
assert_eq!(rewritten[1], "-c");
@@ -145,8 +165,13 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() {
"arg1".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&[],
);
assert!(
rewritten[2]
@@ -171,8 +196,13 @@ fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() {
"echo hello".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd, &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
&command_cwd,
&HashMap::new(),
&[],
);
assert_eq!(rewritten, command);
}
@@ -195,8 +225,13 @@ fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() {
];
let command_cwd = dir.path().join(".");
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd, &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
&command_cwd,
&HashMap::new(),
&[],
);
assert_eq!(rewritten[0], "/bin/zsh");
assert_eq!(rewritten[1], "-c");
@@ -231,6 +266,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() {
&session_shell,
dir.path(),
&explicit_env_overrides,
&[],
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
@@ -265,8 +301,13 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() {
"-lc".to_string(),
"printf '%s' \"$PATH\"".to_string(),
];
let rewritten =
maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path(), &HashMap::new());
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&[],
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.output()
@@ -302,6 +343,7 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() {
&session_shell,
dir.path(),
&explicit_env_overrides,
&[],
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
@@ -342,6 +384,7 @@ fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() {
&session_shell,
dir.path(),
&explicit_env_overrides,
&[],
);
assert!(!rewritten[2].contains("super-secret-value"));
@@ -386,6 +429,7 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() {
&session_shell,
dir.path(),
&explicit_env_overrides,
&[],
);
let output = Command::new(&rewritten[0])
@@ -396,3 +440,87 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() {
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "unset");
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_appends_runtime_git_config_after_snapshot() {
let dir = tempdir().expect("create temp dir");
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(
&snapshot_path,
"# Snapshot file\nexport GIT_CONFIG_COUNT='1'\nexport GIT_CONFIG_KEY_0='user.name'\nexport GIT_CONFIG_VALUE_0='Pavel'\n",
)
.expect("write snapshot");
let session_shell = shell_with_snapshot(
ShellType::Bash,
"/bin/bash",
snapshot_path,
dir.path().to_path_buf(),
);
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printf '%s|%s|%s|%s|%s' \"$GIT_CONFIG_COUNT\" \"$GIT_CONFIG_KEY_0\" \"$GIT_CONFIG_VALUE_0\" \"$GIT_CONFIG_KEY_1\" \"$GIT_CONFIG_VALUE_1\"".to_string(),
];
let runtime_git_config_overrides =
vec![("core.hooksPath".to_string(), "/codex/hooks".to_string())];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&runtime_git_config_overrides,
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.output()
.expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"2|user.name|Pavel|core.hooksPath|/codex/hooks"
);
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_skips_duplicate_runtime_git_config_from_snapshot() {
let dir = tempdir().expect("create temp dir");
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(
&snapshot_path,
"# Snapshot file\nexport GIT_CONFIG_COUNT='1'\nexport GIT_CONFIG_KEY_0='core.hooksPath'\nexport GIT_CONFIG_VALUE_0='/user/hooks'\n",
)
.expect("write snapshot");
let session_shell = shell_with_snapshot(
ShellType::Bash,
"/bin/bash",
snapshot_path,
dir.path().to_path_buf(),
);
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printf '%s|%s|%s|%s|%s' \"${GIT_CONFIG_COUNT:-}\" \"${GIT_CONFIG_KEY_0:-}\" \"${GIT_CONFIG_VALUE_0:-}\" \"${GIT_CONFIG_KEY_1:-}\" \"${GIT_CONFIG_VALUE_1:-}\"".to_string(),
];
let runtime_git_config_overrides =
vec![("core.hooksPath".to_string(), "/codex/hooks".to_string())];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
dir.path(),
&HashMap::new(),
&runtime_git_config_overrides,
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.output()
.expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"1|core.hooksPath|/user/hooks||"
);
}

View File

@@ -48,6 +48,7 @@ pub struct ShellRequest {
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
pub explicit_env_overrides: HashMap<String, String>,
pub runtime_git_config_overrides: Vec<(String, String)>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
@@ -225,6 +226,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
session_shell.as_ref(),
&req.cwd,
&req.explicit_env_overrides,
&req.runtime_git_config_overrides,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell)
&& ctx.session.features().enabled(Feature::PowershellUtf8)

View File

@@ -50,6 +50,7 @@ pub struct UnifiedExecRequest {
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub explicit_env_overrides: HashMap<String, String>,
pub runtime_git_config_overrides: Vec<(String, String)>,
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
@@ -195,6 +196,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
session_shell.as_ref(),
&req.cwd,
&req.explicit_env_overrides,
&req.runtime_git_config_overrides,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell)
&& ctx.session.features().enabled(Feature::PowershellUtf8)

View File

@@ -13,8 +13,10 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::commit_attribution::configure_git_hooks_env_for_config;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::features::Feature;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::ExecRequest;
use crate::tools::context::ExecCommandToolOutput;
@@ -570,10 +572,18 @@ impl UnifiedExecProcessManager {
cwd: PathBuf,
context: &UnifiedExecContext,
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
let env = apply_unified_exec_env(create_env(
let mut env = create_env(
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
));
);
let runtime_git_config_overrides = if context.turn.features.enabled(Feature::CodexGitCommit)
{
configure_git_hooks_env_for_config(&mut env, context.turn.config.as_ref())
} else {
Vec::new()
};
let env = apply_unified_exec_env(env);
let explicit_env_overrides = context.turn.shell_environment_policy.r#set.clone();
let mut orchestrator = ToolOrchestrator::new();
let mut runtime =
UnifiedExecRuntime::new(self, context.turn.tools_config.unified_exec_backend);
@@ -598,7 +608,8 @@ impl UnifiedExecProcessManager {
command: request.command.clone(),
cwd,
env,
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
explicit_env_overrides,
runtime_git_config_overrides,
network: request.network.clone(),
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,

View File

@@ -0,0 +1,340 @@
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use core_test_support::assert_regex_match;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_windows;
use core_test_support::test_codex_exec::TestCodexExecBuilder;
use core_test_support::test_codex_exec::test_codex_exec;
use pretty_assertions::assert_eq;
use serde_json::json;
const CALL_ID: &str = "commit-attribution-shell-command";
const COMMIT_MESSAGE: &str = "commit attribution test";
fn git_test_env() -> [(&'static str, &'static str); 2] {
[
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_NOSYSTEM", "1"),
]
}
fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.envs(git_test_env())
.args(args)
.current_dir(repo)
.output()
.with_context(|| format!("run git {}", args.join(" ")))?;
ensure!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn init_repo(repo: &Path) -> Result<()> {
run_git(repo, &["init"])?;
run_git(repo, &["config", "user.name", "Commit Attribution Test"])?;
run_git(
repo,
&["config", "user.email", "commit-attribution@example.com"],
)?;
Ok(())
}
fn write_config(home: &Path, commit_attribution_line: Option<&str>) -> Result<()> {
let mut config = String::new();
if let Some(line) = commit_attribution_line {
config.push_str(line);
config.push('\n');
config.push('\n');
}
config.push_str("[features]\ncodex_git_commit = true\n");
std::fs::write(home.join("config.toml"), config).context("write config.toml")
}
async fn mount_commit_turn(server: &wiremock::MockServer, command: &str) -> Result<ResponseMock> {
let arguments = serde_json::to_string(&json!({
"command": command,
"login": false,
"timeout_ms": 10_000,
}))?;
Ok(mount_sse_sequence(
server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(CALL_ID, "shell_command", &arguments),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await)
}
fn run_exec(
builder: &TestCodexExecBuilder,
server: &wiremock::MockServer,
) -> Result<std::process::Output> {
let mut cmd = builder.cmd_with_server(server);
cmd.timeout(Duration::from_secs(30));
cmd.envs(git_test_env())
.arg("--dangerously-bypass-approvals-and-sandbox")
.arg("--skip-git-repo-check")
.arg("make the requested commit");
cmd.output().context("run codex-exec")
}
fn latest_commit_message(repo: &Path) -> Result<String> {
run_git(repo, &["log", "-1", "--format=%B"])
}
fn assert_shell_command_succeeded(mock: &ResponseMock) {
let Some(output) = mock.function_call_output_text(CALL_ID) else {
panic!("shell_command output should be recorded");
};
assert_regex_match(
r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n.*$",
&output.replace("\r\n", "\n"),
);
}
fn codex_hooks_dir(builder: &TestCodexExecBuilder) -> std::path::PathBuf {
builder.home_path().join("hooks").join("commit-attribution")
}
fn assert_trailer_once(message: &str, expected: &str) {
assert!(
message.trim_end().ends_with(expected),
"expected commit message to end with {expected:?}, got: {message:?}"
);
assert_eq!(message.matches(expected).count(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_commit_attribution_defaults_when_unset() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex_exec();
init_repo(builder.cwd_path())?;
write_config(builder.home_path(), None)?;
let mock = mount_commit_turn(
&server,
&format!("git commit --allow-empty -m '{COMMIT_MESSAGE}'"),
)
.await?;
let output = run_exec(&builder, &server)?;
assert!(
output.status.success(),
"codex-exec failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_shell_command_succeeded(&mock);
let message = latest_commit_message(builder.cwd_path())?;
assert_trailer_once(&message, "Co-authored-by: Codex <noreply@openai.com>");
assert!(
codex_hooks_dir(&builder)
.join("prepare-commit-msg")
.exists()
);
assert!(codex_hooks_dir(&builder).join("commit-msg").exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_commit_attribution_uses_configured_value() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex_exec();
init_repo(builder.cwd_path())?;
write_config(
builder.home_path(),
Some(r#"commit_attribution = "AgentX <agent@example.com>""#),
)?;
let mock = mount_commit_turn(
&server,
&format!("git commit --allow-empty -m '{COMMIT_MESSAGE}'"),
)
.await?;
let output = run_exec(&builder, &server)?;
assert!(
output.status.success(),
"codex-exec failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_shell_command_succeeded(&mock);
let message = latest_commit_message(builder.cwd_path())?;
assert_trailer_once(&message, "Co-authored-by: AgentX <agent@example.com>");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_commit_attribution_can_be_disabled() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex_exec();
init_repo(builder.cwd_path())?;
write_config(builder.home_path(), Some(r#"commit_attribution = """#))?;
let mock = mount_commit_turn(
&server,
&format!("git commit --allow-empty -m '{COMMIT_MESSAGE}'"),
)
.await?;
let output = run_exec(&builder, &server)?;
assert!(
output.status.success(),
"codex-exec failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_shell_command_succeeded(&mock);
let message = latest_commit_message(builder.cwd_path())?;
assert!(!message.contains("Co-authored-by:"));
assert!(!codex_hooks_dir(&builder).exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_commit_attribution_ignores_malformed_config() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex_exec();
init_repo(builder.cwd_path())?;
write_config(builder.home_path(), Some("commit_attribution = true"))?;
let mock = mount_commit_turn(
&server,
&format!("git commit --allow-empty -m '{COMMIT_MESSAGE}'"),
)
.await?;
let output = run_exec(&builder, &server)?;
assert!(
output.status.success(),
"codex-exec failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_shell_command_succeeded(&mock);
let message = latest_commit_message(builder.cwd_path())?;
assert_trailer_once(&message, "Co-authored-by: Codex <noreply@openai.com>");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_commit_attribution_preserves_user_commit_hooks() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex_exec();
init_repo(builder.cwd_path())?;
write_config(builder.home_path(), None)?;
let user_hooks = builder.cwd_path().join("user-hooks");
std::fs::create_dir_all(&user_hooks).context("create user hooks dir")?;
std::fs::write(
user_hooks.join("pre-commit"),
"#!/usr/bin/env bash\nset -euo pipefail\nroot=\"$(git rev-parse --show-toplevel)\"\nprintf pre-commit > \"$root/pre-commit.marker\"\n",
)?;
std::fs::write(
user_hooks.join("post-commit"),
"#!/usr/bin/env bash\nset -euo pipefail\nroot=\"$(git rev-parse --show-toplevel)\"\nprintf post-commit > \"$root/post-commit.marker\"\n",
)?;
std::fs::write(
user_hooks.join("prepare-commit-msg"),
"#!/usr/bin/env bash\nset -euo pipefail\nroot=\"$(git rev-parse --show-toplevel)\"\nprintf prepare-commit-msg > \"$root/prepare-commit-msg.marker\"\nprintf '\\nuser-prepare-hook\\n' >> \"$1\"\n",
)?;
std::fs::write(
user_hooks.join("commit-msg"),
"#!/usr/bin/env bash\nset -euo pipefail\nroot=\"$(git rev-parse --show-toplevel)\"\nprintf commit-msg > \"$root/commit-msg.marker\"\n",
)?;
for hook_name in [
"pre-commit",
"post-commit",
"prepare-commit-msg",
"commit-msg",
] {
let mut perms = std::fs::metadata(user_hooks.join(hook_name))?.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
}
std::fs::set_permissions(user_hooks.join(hook_name), perms)?;
}
run_git(
builder.cwd_path(),
&[
"config",
"core.hooksPath",
user_hooks.to_string_lossy().as_ref(),
],
)?;
let mock = mount_commit_turn(
&server,
&format!("git commit --allow-empty -m '{COMMIT_MESSAGE}'"),
)
.await?;
let output = run_exec(&builder, &server)?;
assert!(
output.status.success(),
"codex-exec failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_shell_command_succeeded(&mock);
let message = latest_commit_message(builder.cwd_path())?;
assert!(message.contains("user-prepare-hook"));
assert_trailer_once(&message, "Co-authored-by: Codex <noreply@openai.com>");
for marker in [
"pre-commit.marker",
"post-commit.marker",
"prepare-commit-msg.marker",
"commit-msg.marker",
] {
assert!(
builder.cwd_path().join(marker).exists(),
"expected {marker} to be created by the user's hook"
);
}
Ok(())
}

View File

@@ -68,6 +68,8 @@ mod client_websockets;
mod code_mode;
mod codex_delegate;
mod collaboration_instructions;
#[cfg(not(target_os = "windows"))]
mod commit_attribution;
mod compact;
mod compact_remote;
mod compact_resume_fork;