#![cfg(unix)]
use nix::errno::Errno;
use nix::fcntl::{FcntlArg, OFlag, fcntl};
use nix::libc;
use nix::pty::openpty;
use nix::sys::signal::{Signal, kill, killpg};
use nix::sys::termios::{LocalFlags, SetArg, SpecialCharacterIndices, tcgetattr, tcsetattr};
use nix::unistd::Pid;
use serde_json::Value;
use std::collections::BTreeMap;
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs::{self, File};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::{PermissionsExt, symlink};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use tempfile::{Builder, TempDir};
nix::ioctl_write_int_bad!(set_controlling_terminal, libc::TIOCSCTTY);
struct CapturedChild {
child: Child,
reaped: bool,
}
impl Drop for CapturedChild {
fn drop(&mut self) {
if self.reaped {
return;
}
let _ = killpg(Pid::from_raw(self.child.id() as i32), Signal::SIGKILL);
let _ = self.child.kill();
let _ = self.child.wait();
}
}
trait BoundedOutput {
fn bounded_output(&mut self) -> io::Result<Output>;
}
impl BoundedOutput for Command {
fn bounded_output(&mut self) -> io::Result<Output> {
let directory = self.get_current_dir().expect("fixture working directory");
let mut stdout = tempfile::tempfile_in(directory)?;
let mut stderr = tempfile::tempfile_in(directory)?;
self.stdin(Stdio::null())
.stdout(Stdio::from(stdout.try_clone()?))
.stderr(Stdio::from(stderr.try_clone()?))
.process_group(0);
let mut child = CapturedChild {
child: self.spawn()?,
reaped: false,
};
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = child.child.try_wait()? {
child.reaped = true;
break status;
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"fixture command exceeded ten seconds",
));
}
std::thread::sleep(Duration::from_millis(10));
};
stdout.seek(SeekFrom::Start(0))?;
stderr.seek(SeekFrom::Start(0))?;
let mut output = Output {
status,
stdout: Vec::new(),
stderr: Vec::new(),
};
stdout.read_to_end(&mut output.stdout)?;
stderr.read_to_end(&mut output.stderr)?;
Ok(output)
}
}
fn shell_status(status: ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap())
}
fn cli() -> PathBuf {
env::var_os("CODEX_COMMIT_BIN")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_codex-commit")))
.canonicalize()
.expect("compiled or explicitly selected CLI")
}
fn mock_codex() -> &'static Path {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let path = Path::new(env!("CARGO_BIN_EXE_codex-commit"))
.parent()
.expect("binary directory")
.join("examples/mock-codex");
assert!(
path.is_file(),
"Cargo must build the Rust mock example: {}",
path.display()
);
path
})
}
fn real_git() -> &'static Path {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
env::split_paths(&env::var_os("PATH").unwrap_or_default())
.map(|directory| directory.join("git"))
.find(|path| {
fs::metadata(path).is_ok_and(|metadata| {
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
})
})
.expect("Git is required for integration tests")
.canonicalize()
.expect("Git path")
})
}
fn text(output: &[u8]) -> String {
String::from_utf8_lossy(output).into_owned()
}
fn assert_code(output: &Output, code: i32) {
assert_eq!(
output.status.code(),
Some(code),
"stdout: {}\nstderr: {}",
text(&output.stdout),
text(&output.stderr)
);
}
fn decode_os(value: &Value) -> OsString {
OsString::from_vec(
value
.as_array()
.expect("raw byte array")
.iter()
.map(|byte| u8::try_from(byte.as_u64().expect("byte")).expect("u8"))
.collect(),
)
}
struct Invocation {
args: Vec<OsString>,
cwd: PathBuf,
pid: u32,
}
impl Invocation {
fn option(&self, name: &str) -> &OsStr {
let position = self
.args
.iter()
.position(|arg| arg == name)
.expect("option");
&self.args[position + 1]
}
fn has(&self, arg: &str) -> bool {
self.args.iter().any(|item| item == arg)
}
fn prompt(&self) -> &str {
self.args
.last()
.and_then(|arg| arg.to_str())
.expect("UTF-8 prompt")
}
}
struct Fixture {
_temporary: Option<TempDir>,
root: PathBuf,
repo: PathBuf,
bin: PathBuf,
log: PathBuf,
child_log: PathBuf,
environment: BTreeMap<OsString, OsString>,
}
impl Fixture {
fn new() -> Self {
let supplied = env::var_os("CODEX_COMMIT_TEST_TMPDIR").map(PathBuf::from);
let temporary = if let Some(root) = &supplied {
fs::create_dir_all(root).expect("scratch root");
Builder::new().prefix("rust-cli-").tempdir_in(root)
} else {
Builder::new().prefix("codex-commit-tests-").tempdir()
}
.expect("fixture temporary directory");
let root = temporary
.path()
.canonicalize()
.expect("canonical scratch root");
let temporary = if supplied.is_some() {
let _ = temporary.keep();
None
} else {
Some(temporary)
};
let repo = root.join("repository with spaces");
let bin = root.join("bin");
fs::create_dir(&repo).expect("repository directory");
fs::create_dir(&bin).expect("mock executable directory");
fs::copy(mock_codex(), bin.join("codex")).expect("copy native Codex mock");
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755))
.expect("executable mock");
let path = env::join_paths(
std::iter::once(bin.clone())
.chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())),
)
.expect("fixture PATH");
let log = root.join("codex.json");
let child_log = root.join("child.json");
let environment = [
(OsString::from("PATH"), path),
(OsString::from("HOME"), root.as_os_str().to_owned()),
(OsString::from("TMPDIR"), root.as_os_str().to_owned()),
(OsString::from("FAKE_CODEX_LOG"), log.as_os_str().to_owned()),
(
OsString::from("FAKE_CODEX_CHILD_LOG"),
child_log.as_os_str().to_owned(),
),
(OsString::from("GIT_CONFIG_NOSYSTEM"), OsString::from("1")),
(
OsString::from("GIT_CONFIG_GLOBAL"),
OsString::from("/dev/null"),
),
(
OsString::from("GIT_AUTHOR_NAME"),
OsString::from("Test Author"),
),
(
OsString::from("GIT_AUTHOR_EMAIL"),
OsString::from("test@example.invalid"),
),
(
OsString::from("GIT_COMMITTER_NAME"),
OsString::from("Test Author"),
),
(
OsString::from("GIT_COMMITTER_EMAIL"),
OsString::from("test@example.invalid"),
),
(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")),
]
.into_iter()
.collect();
let fixture = Self {
_temporary: temporary,
root,
repo,
bin,
log,
child_log,
environment,
};
fixture.git(&["init", "-b", "main"]);
fs::write(fixture.repo.join("tracked.txt"), "original\n").expect("fixture content");
fixture.git(&["add", "tracked.txt"]);
fixture.git(&["commit", "-m", "Initial fixture"]);
fixture
}
fn command(&self, program: impl AsRef<OsStr>) -> Command {
let mut command = Command::new(program);
command
.env_clear()
.envs(&self.environment)
.current_dir(&self.repo);
command
}
fn git(&self, args: &[&str]) -> String {
self.git_at(&self.repo, args)
}
fn git_at(&self, directory: &Path, args: &[&str]) -> String {
let output = self
.command(real_git())
.current_dir(directory)
.args(args)
.bounded_output()
.expect("run Git");
assert_code(&output, 0);
text(&output.stdout).trim().to_owned()
}
fn invoke(&self, args: &[&str]) -> Output {
self.command(cli())
.args(args)
.bounded_output()
.expect("run CLI")
}
fn invocation(&self) -> Invocation {
let value: Value =
serde_json::from_slice(&fs::read(&self.log).expect("mock log")).expect("JSON log");
Invocation {
args: value["argv"]
.as_array()
.expect("argv")
.iter()
.map(decode_os)
.collect(),
cwd: PathBuf::from(decode_os(&value["cwd"])),
pid: u32::try_from(value["pid"].as_u64().expect("PID")).expect("u32 PID"),
}
}
fn empty_bin(&self, name: &str) -> PathBuf {
let directory = self.root.join(name);
fs::create_dir(&directory).expect("new bin directory");
directory
}
fn git_only(&self) -> PathBuf {
let directory = self.empty_bin("git-only");
symlink(real_git(), directory.join("git")).expect("Git symlink");
directory
}
}
#[test]
fn default_launch_from_nested_directory_uses_luna_max_and_saved_thread() {
let fixture = Fixture::new();
let nested = fixture.repo.join("nested");
fs::create_dir(&nested).unwrap();
let output = fixture
.command(cli())
.current_dir(nested)
.bounded_output()
.unwrap();
assert_code(&output, 0);
let call = fixture.invocation();
assert_eq!(call.cwd, fixture.repo);
assert!(call.has("exec"));
assert_eq!(call.option("--model"), "gpt-6-luna");
assert!(call.has("model_reasoning_effort=\"max\""));
assert!(call.has("service_tier=\"default\""));
assert_eq!(call.option("-C"), fixture.repo);
assert!(!call.has("--sandbox"));
assert!(!call.has("-a"));
assert!(
!call
.args
.iter()
.any(|arg| arg.to_string_lossy().starts_with("approval_policy="))
);
assert!(!call.has("--ephemeral"));
assert!(call.has("--json"));
assert!(call.prompt().contains("Push policy: disabled."));
assert!(text(&output.stdout).contains("finished"));
}
#[test]
fn directory_option_and_interactive_permissions() {
let fixture = Fixture::new();
let output = fixture
.command(cli())
.current_dir(&fixture.root)
.arg("-C")
.arg(&fixture.repo)
.args(["--interactive", "--push"])
.bounded_output()
.unwrap();
assert_code(&output, 0);
let call = fixture.invocation();
assert!(!call.has("exec"));
assert!(call.has("--no-alt-screen"));
assert!(!call.has("-a"));
assert!(!call.has("--sandbox"));
assert!(
!call
.args
.iter()
.any(|arg| arg.to_string_lossy().starts_with("approval_policy="))
);
assert_eq!(call.option("--model"), "gpt-6-luna");
assert!(call.has("model_reasoning_effort=\"max\""));
assert!(call.prompt().contains("Push policy: enabled."));
}
fn write_config(path: &Path, source: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, source).unwrap();
}
#[test]
fn global_config_uses_absolute_xdg_or_home_fallback() {
let fixture = Fixture::new();
let home_config = fixture.root.join(".config/codex-commit/config.toml");
write_config(&home_config, "sandbox = 'read-only'\nprofile = 'home'\n");
for xdg in [None, Some(""), Some("relative-config")] {
let mut command = fixture.command(cli());
if let Some(value) = xdg {
command.env("XDG_CONFIG_HOME", value);
}
let output = command.arg("--dry-run").bounded_output().unwrap();
assert_code(&output, 0);
let stdout = text(&output.stdout);
assert!(stdout.contains(&format!("Config: {}", home_config.display())));
assert!(stdout.contains("Sandbox override: read-only"));
assert!(stdout.contains("Profile: home"));
}
let xdg = fixture.root.join("xdg");
let xdg_config = xdg.join("codex-commit/config.toml");
write_config(&xdg_config, "profile = 'xdg'");
let output = fixture
.command(cli())
.env("XDG_CONFIG_HOME", xdg)
.arg("--dry-run")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert!(text(&output.stdout).contains("Profile: xdg"));
assert!(text(&output.stdout).contains(&format!("Config: {}", xdg_config.display())));
assert!(text(&output.stdout).contains("Sandbox override: none"));
for home in [None, Some("relative-home")] {
let mut command = fixture.command(cli());
command
.env_remove("HOME")
.env("XDG_CONFIG_HOME", "relative-config");
if let Some(value) = home {
command.env("HOME", value);
}
let output = command.arg("--dry-run").bounded_output().unwrap();
assert_code(&output, 0);
assert!(text(&output.stdout).contains("Config: none"));
}
assert!(!fixture.log.exists());
}
#[test]
fn config_precedence_and_explicit_relative_path_are_forwarded() {
let fixture = Fixture::new();
let default = fixture.root.join(".config/codex-commit/config.toml");
write_config(
&default,
"sandbox = 'read-only'\nprofile = 'global'\napproval_policy = 'on-request'",
);
let output = fixture.invoke(&["-a", "never"]);
assert_code(&output, 0);
let call = fixture.invocation();
assert_eq!(call.option("--profile"), "global");
assert_eq!(call.option("--sandbox"), "read-only");
assert!(call.has("approval_policy=\"never\""));
let output = fixture.invoke(&["--interactive", "--sandbox", "workspace-write", "-p", "cli"]);
assert_code(&output, 0);
let call = fixture.invocation();
assert_eq!(call.option("--sandbox"), "workspace-write");
assert_eq!(call.option("--profile"), "cli");
assert!(call.has("approval_policy=\"on-request\""));
write_config(&fixture.repo.join("custom.toml"), "profile = 'explicit'");
assert_code(&fixture.invoke(&["--config", "custom.toml"]), 0);
let call = fixture.invocation();
assert_eq!(call.option("--profile"), "explicit");
assert!(!call.has("--sandbox"));
assert!(!call.has("approval_policy=\"on-request\""));
assert_code(&fixture.invoke(&["--no-config"]), 0);
assert!(!fixture.invocation().has("--profile"));
}
#[test]
fn config_failures_are_reported_before_git_or_codex() {
let fixture = Fixture::new();
let config = fixture.root.join(".config/codex-commit/config.toml");
let empty = fixture.empty_bin("empty-config-bin");
for source in [
"[broken",
"unknown = true",
"sandbox = 'invalid'",
"approval_policy = 'untrusted'",
"profile = 42",
"sandbox = 1",
"approval_policy = 'on-request'",
] {
write_config(&config, source);
let output = fixture
.command(cli())
.env("PATH", &empty)
.bounded_output()
.unwrap();
assert_code(&output, 2);
let stderr = text(&output.stderr);
if source.contains("on-request") {
assert!(stderr.contains("requires --interactive"));
} else {
assert!(stderr.contains(&config.display().to_string()), "{stderr}");
}
assert!(!fixture.log.exists());
}
fs::remove_file(&config).unwrap();
fs::create_dir(&config).unwrap();
let output = fixture
.command(cli())
.env("PATH", &empty)
.bounded_output()
.unwrap();
assert_code(&output, 2);
assert!(text(&output.stderr).contains("could not read config"));
assert!(!fixture.log.exists());
}
#[test]
fn config_missing_explicit_and_opt_out_behavior() {
let fixture = Fixture::new();
let output = fixture.invoke(&["--dry-run"]);
assert_code(&output, 0);
assert!(text(&output.stdout).contains("Config: none"));
let missing = fixture.root.join("missing.toml");
let output = fixture
.command(cli())
.arg("--config")
.arg(&missing)
.bounded_output()
.unwrap();
assert_code(&output, 2);
assert!(text(&output.stderr).contains(&missing.display().to_string()));
write_config(
&fixture.root.join(".config/codex-commit/config.toml"),
"invalid ???",
);
assert_code(&fixture.invoke(&["--no-config", "--dry-run"]), 0);
for args in [&["--help"][..], &["--version"], &["--completions", "zsh"]] {
assert_code(&fixture.invoke(args), 0);
}
for args in [
&["--config"][..],
&["--config", "missing.toml", "--no-config"],
&["--completions", "zsh", "--no-config"],
&["--completions", "zsh", "--config", "missing.toml"],
] {
assert_code(&fixture.invoke(args), 2);
}
assert!(!fixture.log.exists());
}
#[test]
fn model_and_reasoning_config_merge_independently_with_cli() {
let fixture = Fixture::new();
let config = fixture.root.join(".config/codex-commit/config.toml");
write_config(
&config,
"model = 'custom-model'\nmodel_reasoning_effort = 'medium'",
);
for (flags, model, effort) in [
(vec![], "custom-model", "medium"),
(
vec!["--interactive", "-m", "cli-model"],
"cli-model",
"medium",
),
(vec!["--reasoning-effort", "low"], "custom-model", "low"),
(
vec![
"--interactive",
"--model",
"both",
"--reasoning-effort",
"high",
],
"both",
"high",
),
] {
let output = fixture.invoke(&flags);
assert_code(&output, 0);
let call = fixture.invocation();
assert_eq!(call.option("--model"), model);
assert!(call.has(&format!("model_reasoning_effort=\"{effort}\"")));
assert!(call.has("service_tier=\"default\""));
assert_eq!(call.has("exec"), !flags.contains(&"--interactive"));
assert!(text(&output.stderr).contains(&format!("({model}, {effort} reasoning;")));
}
write_config(&config, "model = 'model-only'");
assert_code(&fixture.invoke(&[]), 0);
assert!(fixture.invocation().has("model_reasoning_effort=\"max\""));
write_config(&config, "model_reasoning_effort = 'none'");
assert_code(&fixture.invoke(&[]), 0);
assert_eq!(fixture.invocation().option("--model"), "gpt-6-luna");
assert!(fixture.invocation().has("model_reasoning_effort=\"none\""));
}
#[test]
fn selected_model_and_reasoning_are_shown_in_dry_run() {
let fixture = Fixture::new();
write_config(
&fixture.root.join(".config/codex-commit/config.toml"),
"model = 'configured-model'\nmodel_reasoning_effort = 'minimal'",
);
for (flags, model, effort) in [
(vec!["--dry-run"], "configured-model", "minimal"),
(
vec![
"--dry-run",
"-m",
"selected-model",
"--reasoning-effort",
"xhigh",
],
"selected-model",
"xhigh",
),
] {
let output = fixture.invoke(&flags);
assert_code(&output, 0);
let stdout = text(&output.stdout);
assert!(stdout.contains(&format!("Model: {model}")));
assert!(stdout.contains(&format!("Reasoning: {effort}")));
}
assert!(!fixture.log.exists());
}
#[test]
fn model_and_reasoning_are_validated_before_git() {
let fixture = Fixture::new();
let empty = fixture.empty_bin("empty-model-bin");
for flags in [
&["-m"][..],
&["--model", ""],
&["-m", " \t"],
&["--reasoning-effort"],
&["--reasoning-effort", "ultra"],
&["--reasoning-effort", "invalid"],
&["--completions", "zsh", "--model", "test"],
&["--completions", "zsh", "--reasoning-effort", "max"],
] {
let output = fixture
.command(cli())
.env("PATH", &empty)
.args(flags)
.bounded_output()
.unwrap();
assert_code(&output, 2);
}
let config = fixture.root.join(".config/codex-commit/config.toml");
for source in [
"model = ''",
"model = ' '",
"model = 42",
"model_reasoning_effort = 'ultra'",
"model_reasoning_effort = 42",
] {
write_config(&config, source);
let output = fixture
.command(cli())
.env("PATH", &empty)
.bounded_output()
.unwrap();
assert_code(&output, 2);
assert!(text(&output.stderr).contains(&config.display().to_string()));
}
assert!(!fixture.log.exists());
}
#[test]
fn permission_overrides_and_profiles_are_explicit_in_both_modes() {
let fixture = Fixture::new();
for (interactive, sandbox, approval) in [
(false, Some("read-only"), None),
(false, Some("danger-full-access"), Some("never")),
(false, None, Some("never")),
(true, None, None),
(true, Some("workspace-write"), None),
(true, None, Some("on-request")),
(true, Some("read-only"), Some("never")),
] {
let mut args = vec!["--profile", "personal"];
if interactive {
args.push("--interactive");
}
if let Some(value) = sandbox {
args.extend(["--sandbox", value]);
}
if let Some(value) = approval {
args.extend(["-a", value]);
}
assert_code(&fixture.invoke(&args), 0);
let call = fixture.invocation();
assert_eq!(call.has("exec"), !interactive);
assert_eq!(call.option("--profile"), "personal");
assert_eq!(call.has("--sandbox"), sandbox.is_some());
if let Some(value) = sandbox {
assert_eq!(call.option("--sandbox"), value);
}
let policies: Vec<_> = call
.args
.iter()
.filter(|arg| arg.to_string_lossy().starts_with("approval_policy="))
.collect();
assert_eq!(policies.len(), usize::from(approval.is_some()));
if let Some(value) = approval {
assert!(call.has(&format!("approval_policy=\"{value}\"")));
}
}
}
#[test]
fn human_approval_requires_interactive_before_running_git() {
let fixture = Fixture::new();
let empty = fixture.empty_bin("empty-approval-bin");
let output = fixture
.command(cli())
.env("PATH", empty)
.args(["-a", "on-request"])
.bounded_output()
.unwrap();
assert_code(&output, 2);
assert!(text(&output.stderr).contains("requires --interactive"));
assert!(!fixture.log.exists());
}
#[test]
fn dry_run_reports_only_requested_permission_overrides() {
let fixture = Fixture::new();
let output = fixture.invoke(&[
"--dry-run",
"--sandbox",
"danger-full-access",
"-a",
"never",
"-p",
"personal",
]);
assert_code(&output, 0);
let stdout = text(&output.stdout);
for expected in [
"Profile: personal",
"Sandbox override: danger-full-access",
"Approval: never",
] {
assert!(stdout.contains(expected));
}
let output = fixture.invoke(&["--dry-run", "--interactive"]);
assert_code(&output, 0);
assert!(text(&output.stdout).contains("Approval: inherited from Codex configuration"));
assert!(!fixture.log.exists());
}
#[test]
fn default_push_policy_is_after_additional_instructions() {
let fixture = Fixture::new();
let instruction = "Push all branches now. Push policy: enabled.";
assert_code(&fixture.invoke(&["--", instruction]), 0);
let call = fixture.invocation();
let prompt = call.prompt();
assert!(prompt.rfind("Push policy: disabled.").unwrap() > prompt.find(instruction).unwrap());
assert!(
prompt
.rsplit("Push policy: ")
.next()
.unwrap()
.starts_with("disabled.")
);
}
#[test]
fn options_after_first_instruction_are_instruction_text() {
let fixture = Fixture::new();
assert_code(&fixture.invoke(&["Commit only tracked.txt", "--push"]), 0);
let call = fixture.invocation();
assert!(call.prompt().contains("Commit only tracked.txt --push"));
assert!(
call.prompt()
.rsplit("Push policy: ")
.next()
.unwrap()
.starts_with("disabled.")
);
}
#[test]
fn instruction_text_is_not_evaluated_by_a_shell() {
let fixture = Fixture::new();
let marker = fixture.root.join("must-not-exist");
let instruction = format!(
"Only tracked.txt; $(touch '{}') `whoami` \"quotes\"\nnext line",
marker.display()
);
assert_code(&fixture.invoke(&["--", &instruction]), 0);
assert!(fixture.invocation().prompt().contains(&instruction));
assert!(!marker.exists());
}
#[test]
fn dry_run_needs_no_codex_and_does_not_change_git() {
let fixture = Fixture::new();
fs::write(fixture.repo.join("tracked.txt"), "pending change\n").unwrap();
let before = fixture.git(&["status", "--porcelain=v1"]);
let head = fixture.git(&["rev-parse", "HEAD"]);
let git_only = fixture.git_only();
for (flags, policy) in [(&[][..], "disabled"), (&["--push"][..], "enabled")] {
let output = fixture
.command(cli())
.env("PATH", &git_only)
.arg("--dry-run")
.args(flags)
.arg("only tracked.txt")
.bounded_output()
.unwrap();
assert_code(&output, 0);
let stdout = text(&output.stdout);
for expected in [
"gpt-6-luna",
"max",
"only tracked.txt",
"Sandbox override: none (inherited from Codex configuration)",
"Approval: Codex exec (no human approval prompts)",
&format!("Push: {policy}"),
] {
assert!(stdout.contains(expected), "missing {expected}");
}
assert!(stdout.contains(fixture.repo.to_str().unwrap()));
}
assert!(!fixture.log.exists());
assert_eq!(fixture.git(&["status", "--porcelain=v1"]), before);
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), head);
}
#[test]
fn completions_need_no_git_repository_or_external_tools() {
let fixture = Fixture::new();
let empty = fixture.empty_bin("empty-completion-bin");
for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {
let output = fixture
.command(cli())
.env("PATH", &empty)
.current_dir(&fixture.root)
.args(["--completions", shell])
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert!(output.stderr.is_empty());
let script = text(&output.stdout);
assert!(script.contains("codex-commit"));
assert!(
script.contains("push"),
"missing push completion for {shell}"
);
for flag in [
"sandbox",
"ask-for-approval",
"profile",
"config",
"no-config",
"model",
"reasoning-effort",
] {
assert!(
script.contains(flag),
"missing {flag} completion for {shell}"
);
}
if shell == "zsh" {
assert!(script.starts_with("#compdef codex-commit"));
assert!(script.contains(":DIR:_files -/"));
}
}
assert!(!fixture.log.exists());
}
#[test]
fn generated_zsh_script_completes_flags_and_only_directories_in_real_zle() {
if Command::new("zsh").arg("--version").output().is_err() {
eprintln!("zsh is not installed; skipping shell integration");
return;
}
let fixture = Fixture::new();
let generated = fixture
.command(cli())
.args(["--completions", "zsh"])
.bounded_output()
.unwrap();
assert_code(&generated, 0);
fs::create_dir(fixture.root.join("completion-directory")).unwrap();
fs::write(fixture.root.join("completion-decoy"), "file").unwrap();
let config = fixture.root.join("completion.zsh");
fs::write(
&config,
format!(
r#"
autoload -Uz compinit
compinit -D
{}
bindkey -e
PS1='READY>'
emit_buffer() {{ print -r -- "RESULT:$BUFFER"; }}
zle -N emit_buffer
bindkey '^X' emit_buffer
"#,
text(&generated.stdout)
),
)
.unwrap();
let output = fixture
.command("zsh")
.current_dir(&fixture.root)
.args([
"-f",
"-c",
r#"
zmodload zsh/zpty
zmodload zsh/zselect
zpty -b shell zsh -f
trap 'zpty -d shell' EXIT
zpty -w shell "source ${(q)1}"
wait_for() {
local captured='' chunk
for attempt in {1..60}; do
if zpty -r shell chunk; then captured+=$chunk; fi
if [[ $captured == *$1* ]]; then return 0; fi
zselect -t 5
done
print -ru2 -- "Missing $1 in: $captured"
return 1
}
wait_for 'READY>' || exit 1
zpty -w -n shell $'codex-commit --pu\t\x18'
wait_for 'RESULT:codex-commit --push ' || exit 1
zpty -w -n shell $'\x15codex-commit -C completion-d\t\x18'
wait_for 'RESULT:codex-commit -C completion-directory/' || exit 1
"#,
"zsh",
config.to_str().unwrap(),
])
.bounded_output()
.unwrap();
assert_code(&output, 0);
}
#[test]
fn help_and_version_need_no_external_tools() {
let fixture = Fixture::new();
let empty = fixture.empty_bin("empty-bin");
for arg in ["--help", "--version"] {
let output = fixture
.command(cli())
.env("PATH", &empty)
.current_dir(&fixture.root)
.arg(arg)
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert!(!output.stdout.is_empty());
if arg == "--version" {
assert_eq!(
text(&output.stdout).trim(),
concat!("codex-commit ", env!("CARGO_PKG_VERSION"))
);
}
}
assert!(!fixture.log.exists());
}
#[test]
fn missing_git_and_codex_report_exit_127() {
let fixture = Fixture::new();
let empty = fixture.empty_bin("empty-bin");
let output = fixture
.command(cli())
.env("PATH", empty)
.bounded_output()
.unwrap();
assert_code(&output, 127);
assert!(text(&output.stderr).contains("git"));
let output = fixture
.command(cli())
.env("PATH", fixture.git_only())
.bounded_output()
.unwrap();
assert_code(&output, 127);
assert!(text(&output.stderr).contains("codex"));
assert!(!fixture.log.exists());
}
#[test]
fn non_executable_codex_reports_exit_126() {
let fixture = Fixture::new();
fs::set_permissions(fixture.bin.join("codex"), fs::Permissions::from_mode(0o644)).unwrap();
symlink(real_git(), fixture.bin.join("git")).unwrap();
let output = fixture
.command(cli())
.env("PATH", &fixture.bin)
.bounded_output()
.unwrap();
assert_code(&output, 126);
assert!(text(&output.stderr).contains("codex"));
assert!(!fixture.log.exists());
}
#[test]
fn non_utf8_repository_path_is_preserved() {
let mut fixture = Fixture::new();
let path = fixture
.root
.join(OsString::from_vec(b"repository-\xff".to_vec()));
if let Err(error) = fs::rename(&fixture.repo, &path) {
if [Some(libc::EILSEQ), Some(libc::EINVAL), Some(libc::ENOTSUP)]
.contains(&error.raw_os_error())
{
eprintln!("filesystem rejects non-UTF-8 filenames; Linux CI exercises this case");
return;
}
panic!("rename raw-byte repository: {error}");
}
fixture.repo = path;
let output = fixture
.command(cli())
.current_dir(&fixture.root)
.arg("-C")
.arg(&fixture.repo)
.bounded_output()
.unwrap();
assert_code(&output, 0);
let call = fixture.invocation();
assert_eq!(
call.cwd.as_os_str().as_bytes(),
fixture.repo.as_os_str().as_bytes()
);
assert_eq!(
call.option("-C").as_bytes(),
fixture.repo.as_os_str().as_bytes()
);
}
#[test]
fn invalid_args_and_unsuitable_repositories_do_not_launch_codex() {
let fixture = Fixture::new();
for args in [
&["--unknown"][..],
&["-C"],
&["--sandbox"],
&["--sandbox", "invalid"],
&["-a"],
&["-a", "untrusted"],
&["-a", "on-request"],
&["-p"],
&["--completions", "zsh", "--sandbox", "read-only"],
&["--completions", "zsh", "-a", "never"],
&["--completions", "zsh", "-p", "test"],
] {
assert_code(&fixture.invoke(args), 2);
}
assert_code(
&fixture
.command(cli())
.current_dir(&fixture.root)
.bounded_output()
.unwrap(),
1,
);
fixture.git(&["checkout", "--detach"]);
assert_code(&fixture.invoke(&[]), 1);
let bare = fixture.root.join("bare.git");
fixture.git(&["init", "--bare", bare.to_str().unwrap()]);
assert_code(
&fixture
.command(cli())
.current_dir(bare)
.bounded_output()
.unwrap(),
1,
);
assert!(!fixture.log.exists());
}
#[test]
fn codex_failure_status_passes_through() {
let fixture = Fixture::new();
let output = fixture
.command(cli())
.env("FAKE_CODEX_EXIT", "23")
.bounded_output()
.unwrap();
assert_code(&output, 23);
}
struct Terminal {
child: Child,
master: File,
output: Vec<u8>,
finished: bool,
}
impl Terminal {
fn launch(fixture: &Fixture, with_child: bool) -> Self {
Self::launch_mode(fixture, with_child, false)
}
fn launch_mode(fixture: &Fixture, with_child: bool, compact: bool) -> Self {
let terminal = openpty(None, None).expect("open real PTY");
let slave = File::from(terminal.slave);
let master = File::from(terminal.master);
let mut termios = tcgetattr(&slave).expect("terminal attributes");
termios
.local_flags
.insert(LocalFlags::ISIG | LocalFlags::ICANON);
termios.control_chars[SpecialCharacterIndices::VINTR as usize] = 3;
termios.control_chars[SpecialCharacterIndices::VSUSP as usize] = 26;
tcsetattr(&slave, SetArg::TCSANOW, &termios).expect("enable terminal Ctrl-C");
let flags = OFlag::from_bits_truncate(fcntl(&master, FcntlArg::F_GETFL).unwrap());
fcntl(&master, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).unwrap();
let mut command = fixture.command(cli());
if !compact {
command.arg("--verbose");
}
command
.env(
"FAKE_CODEX_MODE",
if with_child { "wait-child" } else { "wait" },
)
.stdin(Stdio::from(slave.try_clone().unwrap()))
.stdout(Stdio::from(slave.try_clone().unwrap()))
.stderr(Stdio::from(slave));
unsafe {
command.pre_exec(|| {
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
set_controlling_terminal(libc::STDIN_FILENO, 0).map_err(io::Error::from)?;
if libc::tcsetpgrp(libc::STDIN_FILENO, libc::getpgrp()) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
Self {
child: command.spawn().expect("spawn CLI in controlling terminal"),
master,
output: Vec::new(),
finished: false,
}
}
fn drain(&mut self) {
let mut buffer = [0; 8192];
loop {
match self.master.read(&mut buffer) {
Ok(0) => break,
Ok(length) => self.output.extend_from_slice(&buffer[..length]),
Err(error)
if error.kind() == io::ErrorKind::WouldBlock
|| error.raw_os_error() == Some(libc::EIO) =>
{
break;
}
Err(error) => panic!("read PTY: {error}"),
}
}
}
fn wait_for_output(&mut self, with_child: bool) {
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
self.drain();
let output = text(&self.output);
if output.contains("live output") && (!with_child || output.contains("child ready")) {
return;
}
assert!(
self.child.try_wait().unwrap().is_none(),
"mock exited before streaming: {output}"
);
std::thread::sleep(Duration::from_millis(10));
}
panic!(
"CLI must stream output while still running: {}",
text(&self.output)
);
}
fn cancel(&mut self, signal: Signal) -> ExitStatus {
if signal == Signal::SIGINT {
self.master.write_all(&[3]).expect("send terminal Ctrl-C");
} else {
kill(Pid::from_raw(self.child.id().try_into().unwrap()), signal).unwrap();
}
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
self.drain();
if let Some(status) = self.child.try_wait().unwrap() {
self.finished = true;
self.drain();
return status;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("cancelled Codex must exit promptly: {}", text(&self.output));
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _ = killpg(Pid::from_raw(self.child.id() as i32), Signal::SIGKILL);
if !self.finished {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
fn terminal_test(signal: Signal, with_child: bool) {
let fixture = Fixture::new();
let mut terminal = Terminal::launch(&fixture, with_child);
terminal.wait_for_output(with_child);
assert!(text(&terminal.output).contains("live progress"));
assert!(
terminal.child.try_wait().unwrap().is_none(),
"output must arrive before completion"
);
assert_eq!(
fixture.invocation().pid,
terminal.child.id(),
"Rust launcher must replace itself with Codex"
);
let child_pid = if with_child {
let child: Value = serde_json::from_slice(&fs::read(&fixture.child_log).unwrap()).unwrap();
assert_eq!(
child["pgid"].as_u64().unwrap(),
u64::from(terminal.child.id())
);
let pid = Pid::from_raw(child["pid"].as_i64().unwrap().try_into().unwrap());
assert!(
kill(pid, None).is_ok(),
"foreground child must be running before cancellation"
);
Some(pid)
} else {
None
};
let status = terminal.cancel(signal);
assert_eq!(status.code(), Some(128 + signal as i32));
assert!(text(&terminal.output).contains("cancelled"));
assert_eq!(
kill(Pid::from_raw(terminal.child.id() as i32), None),
Err(Errno::ESRCH)
);
if let Some(pid) = child_pid {
assert_eq!(
kill(pid, None),
Err(Errno::ESRCH),
"mock must reap its running child"
);
}
}
#[test]
fn real_terminal_streams_before_exit_and_ctrl_c_cancels_same_process() {
terminal_test(Signal::SIGINT, false);
}
#[test]
fn term_cancels_same_process() {
terminal_test(Signal::SIGTERM, false);
}
#[test]
fn ctrl_c_cancels_foreground_child_and_codex_reaps_it() {
terminal_test(Signal::SIGINT, true);
}
#[test]
fn term_cancels_codex_and_mock_reaps_running_child() {
terminal_test(Signal::SIGTERM, true);
}
#[test]
fn mock_model_commits_locally_then_push_publishes_the_same_commit() {
let fixture = Fixture::new();
let remote = fixture.root.join("remote.git");
fixture.git(&["init", "--bare", remote.to_str().unwrap()]);
fixture.git(&["remote", "add", "origin", remote.to_str().unwrap()]);
fixture.git(&["push", "--set-upstream", "origin", "main"]);
let old_head = fixture.git(&["rev-parse", "HEAD"]);
fs::write(fixture.repo.join("tracked.txt"), "change to publish\n").unwrap();
fs::write(fixture.repo.join("new.txt"), "new fixture\n").unwrap();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "git")
.bounded_output()
.unwrap();
assert_code(&output, 0);
let new_head = fixture.git(&["rev-parse", "HEAD"]);
assert_ne!(new_head, old_head);
assert_eq!(
fixture.git_at(&remote, &["rev-parse", "refs/heads/main"]),
old_head
);
assert_eq!(
fixture.git(&["show", "HEAD:tracked.txt"]),
"change to publish"
);
assert_eq!(fixture.git(&["show", "HEAD:new.txt"]), "new fixture");
assert_eq!(fixture.git(&["status", "--porcelain=v1"]), "");
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "git")
.arg("--push")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert_eq!(
fixture.git(&["rev-parse", "HEAD"]),
new_head,
"do not create an empty commit"
);
assert_eq!(
fixture.git_at(&remote, &["rev-parse", "refs/heads/main"]),
new_head
);
assert_eq!(
fixture.git_at(&remote, &["show", "main:tracked.txt"]),
"change to publish"
);
assert_eq!(
fixture.git_at(&remote, &["show", "main:new.txt"]),
"new fixture"
);
assert_eq!(fixture.git(&["status", "--porcelain=v1"]), "");
}
#[test]
fn mock_model_default_commits_without_any_remote() {
let fixture = Fixture::new();
let old_head = fixture.git(&["rev-parse", "HEAD"]);
fs::write(fixture.repo.join("tracked.txt"), "local work\n").unwrap();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "git")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert_ne!(fixture.git(&["rev-parse", "HEAD"]), old_head);
assert_eq!(fixture.git(&["show", "HEAD:tracked.txt"]), "local work");
assert_eq!(fixture.git(&["remote"]), "");
}
#[test]
fn compact_filters_noise_and_keeps_diagnostics() {
let fixture = Fixture::new();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "diagnostic")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert_eq!(text(&output.stdout).trim(), "finished");
let stderr = text(&output.stderr);
assert!(!stderr.contains("hook:"));
assert!(stderr.contains("warning: keep this warning"));
assert!(stderr.contains("fatal: exact push error"));
assert!(stderr.contains("git push"));
}
#[test]
fn compact_rejects_invalid_and_incomplete_events() {
for mode in ["malformed", "truncated"] {
let fixture = Fixture::new();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", mode)
.bounded_output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
}
#[test]
fn verbose_keeps_direct_output() {
let fixture = Fixture::new();
let output = fixture
.command(cli())
.arg("--verbose")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert!(!fixture.invocation().has("--json"));
assert_eq!(text(&output.stdout).trim(), "finished");
}
#[test]
fn compact_terminal_cancellation_reaps_codex_and_descendant() {
for signal in [Signal::SIGINT, Signal::SIGTERM] {
let fixture = Fixture::new();
let mut terminal = Terminal::launch_mode(&fixture, true, true);
terminal.wait_for_output(true);
let codex = fixture.invocation().pid;
assert_ne!(codex, terminal.child.id());
let child: Value = serde_json::from_slice(&fs::read(&fixture.child_log).unwrap()).unwrap();
let descendant = Pid::from_raw(child["pid"].as_i64().unwrap() as i32);
assert_eq!(shell_status(terminal.cancel(signal)), 128 + signal as i32);
assert_eq!(kill(Pid::from_raw(codex as i32), None), Err(Errno::ESRCH));
assert_eq!(kill(descendant, None), Err(Errno::ESRCH));
}
}
#[test]
fn compact_drains_large_streams_without_showing_successful_tool_output() {
let fixture = Fixture::new();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "large")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert_eq!(text(&output.stdout).trim(), "finished");
assert!(!text(&output.stderr).contains("large successful diff"));
assert!(output.stderr.len() > 131072);
}
#[test]
fn compact_preserves_file_stdin_as_codex_context() {
let fixture = Fixture::new();
let input = fixture.root.join("stdin.txt");
fs::write(&input, "context from stdin").unwrap();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "stdin")
.stdin(Stdio::from(File::open(input).unwrap()))
.output()
.unwrap();
assert_code(&output, 0);
assert!(text(&output.stderr).contains("context from stdin"));
assert_eq!(text(&output.stdout).trim(), "finished");
}
#[test]
fn compact_terminal_suspend_and_resume_preserve_job_control() {
use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
let fixture = Fixture::new();
let mut terminal = Terminal::launch_mode(&fixture, true, true);
terminal.wait_for_output(true);
let wrapper = Pid::from_raw(terminal.child.id() as i32);
let codex = fixture.invocation().pid;
let child: Value = serde_json::from_slice(&fs::read(&fixture.child_log).unwrap()).unwrap();
let descendant = child["pid"].as_u64().unwrap();
terminal.master.write_all(&[26]).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match waitpid(wrapper, Some(WaitPidFlag::WUNTRACED | WaitPidFlag::WNOHANG)).unwrap() {
WaitStatus::Stopped(_, Signal::SIGSTOP) => break,
WaitStatus::StillAlive => assert!(Instant::now() < deadline, "wrapper must suspend"),
state => panic!("unexpected wrapper status: {state:?}"),
}
std::thread::sleep(Duration::from_millis(10));
}
for pid in [u64::from(codex), descendant] {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let output = Command::new("ps")
.args(["-o", "stat=", "-p", &pid.to_string()])
.output()
.unwrap();
if text(&output.stdout).trim_start().starts_with('T') {
break;
}
assert!(
Instant::now() < deadline,
"owned process must suspend: {pid}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
kill(wrapper, Signal::SIGCONT).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let output = Command::new("ps")
.args(["-o", "stat=", "-p", &codex.to_string()])
.output()
.unwrap();
if !text(&output.stdout).trim_start().starts_with('T') {
break;
}
assert!(Instant::now() < deadline, "Codex must resume");
std::thread::sleep(Duration::from_millis(10));
}
assert!(terminal.child.try_wait().unwrap().is_none());
assert_eq!(shell_status(terminal.cancel(Signal::SIGTERM)), 143);
assert_eq!(kill(Pid::from_raw(codex as i32), None), Err(Errno::ESRCH));
assert_eq!(
kill(Pid::from_raw(descendant as i32), None),
Err(Errno::ESRCH)
);
}
#[test]
fn compact_exits_when_detached_descendant_retains_pipes() {
struct Escaped(PathBuf);
impl Drop for Escaped {
fn drop(&mut self) {
if let Ok(text) = fs::read_to_string(&self.0) {
if let Ok(pid) = text.parse() {
let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
}
}
}
}
let fixture = Fixture::new();
let escaped = Escaped(fixture.child_log.clone());
let start = Instant::now();
let output = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "escaped")
.bounded_output()
.unwrap();
assert_code(&output, 0);
assert_eq!(text(&output.stdout).trim(), "finished");
assert!(
start.elapsed() < Duration::from_secs(3),
"must not wait for detached descendants"
);
assert!(
kill(
Pid::from_raw(fs::read_to_string(&escaped.0).unwrap().parse().unwrap()),
None
)
.is_ok(),
"wrapper must not signal escaped processes"
);
}
#[test]
fn compact_cancellation_preserves_signal_status_with_partial_json() {
for signal in [Signal::SIGTERM, Signal::SIGINT] {
let fixture = Fixture::new();
let stdout = tempfile::tempfile_in(&fixture.root).unwrap();
let mut stderr = tempfile::tempfile_in(&fixture.root).unwrap();
let mut child = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "partial-wait")
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr.try_clone().unwrap()))
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
stderr.seek(SeekFrom::Start(0)).unwrap();
let mut text = String::new();
stderr.read_to_string(&mut text).unwrap();
if text.contains("partial ready") {
break;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
kill(Pid::from_raw(child.id() as i32), signal).unwrap();
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(shell_status(status), 128 + signal as i32);
stderr.seek(SeekFrom::Start(0)).unwrap();
let mut output = String::new();
stderr.read_to_string(&mut output).unwrap();
assert!(!output.contains("invalid Codex JSON"));
}
}
#[test]
fn compact_blocked_final_output_remains_interruptible_after_codex_exits() {
let fixture = Fixture::new();
let (read, write) = nix::unistd::pipe().unwrap();
let mut reader = File::from(read);
let flags = OFlag::from_bits_truncate(fcntl(&reader, FcntlArg::F_GETFL).unwrap());
fcntl(&reader, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).unwrap();
let stderr = tempfile::tempfile_in(&fixture.root).unwrap();
let child = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "large-final")
.stdout(Stdio::from(write))
.stderr(Stdio::from(stderr))
.process_group(0)
.spawn()
.unwrap();
let mut guard = CapturedChild {
child,
reaped: false,
};
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match reader.read(&mut [0; 1]) {
Ok(1) => break,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
result => panic!("final output expected: {result:?}"),
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(
kill(Pid::from_raw(fixture.invocation().pid as i32), None),
Err(Errno::ESRCH)
);
assert!(
guard.child.try_wait().unwrap().is_none(),
"final output must be blocked"
);
kill(Pid::from_raw(guard.child.id() as i32), Signal::SIGTERM).unwrap();
let status = loop {
if let Some(status) = guard.child.try_wait().unwrap() {
break status;
}
assert!(
Instant::now() < deadline,
"blocked output must remain cancellable"
);
std::thread::sleep(Duration::from_millis(10));
};
guard.reaped = true;
use std::os::unix::process::ExitStatusExt;
assert_eq!(status.signal(), Some(Signal::SIGTERM as i32));
}
#[test]
fn compact_blocked_progress_cancellation_reaps_owned_processes() {
for ignore_cancellation in [false, true] {
let mut fixture = Fixture::new();
if ignore_cancellation {
fixture
.environment
.insert("FAKE_CODEX_IGNORE_CANCELLATION".into(), "1".into());
}
let (read, write) = nix::unistd::pipe().unwrap();
let mut reader = File::from(read);
let flags = OFlag::from_bits_truncate(fcntl(&reader, FcntlArg::F_GETFL).unwrap());
fcntl(&reader, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).unwrap();
let child = fixture
.command(cli())
.env("FAKE_CODEX_MODE", "wait-child")
.env("FAKE_CODEX_LARGE_PROGRESS", "1")
.stdout(Stdio::null())
.stderr(Stdio::from(write))
.process_group(0)
.spawn()
.unwrap();
let mut guard = CapturedChild {
child,
reaped: false,
};
let deadline = Instant::now() + Duration::from_secs(5);
let mut output = Vec::new();
loop {
let mut buffer = [0; 8192];
match reader.read(&mut buffer) {
Ok(length) => output.extend_from_slice(&buffer[..length]),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("read stderr: {error}"),
}
if text(&output).contains("BLOCKED_PROGRESS") {
break;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
let codex = fixture.invocation().pid;
struct AgentCleanup(Pid);
impl Drop for AgentCleanup {
fn drop(&mut self) {
let _ = killpg(self.0, Signal::SIGKILL);
}
}
let _agent_cleanup = AgentCleanup(Pid::from_raw(codex as i32));
let descendant: Value =
serde_json::from_slice(&fs::read(&fixture.child_log).unwrap()).unwrap();
assert!(guard.child.try_wait().unwrap().is_none());
kill(Pid::from_raw(guard.child.id() as i32), Signal::SIGTERM).unwrap();
let status = loop {
if let Some(status) = guard.child.try_wait().unwrap() {
break status;
}
assert!(
Instant::now() < deadline,
"first TERM must interrupt blocked progress"
);
std::thread::sleep(Duration::from_millis(10));
};
guard.reaped = true;
assert_eq!(shell_status(status), 143);
assert_eq!(kill(Pid::from_raw(codex as i32), None), Err(Errno::ESRCH));
assert_eq!(
kill(
Pid::from_raw(descendant["pid"].as_i64().unwrap() as i32),
None
),
Err(Errno::ESRCH)
);
}
}
#[test]
fn compact_cancellation_bounds_unresponsive_codex_shutdown() {
for signal in [
Signal::SIGINT,
Signal::SIGTERM,
Signal::SIGHUP,
Signal::SIGQUIT,
] {
let mut fixture = Fixture::new();
fixture
.environment
.insert("FAKE_CODEX_IGNORE_CANCELLATION".into(), "1".into());
let mut terminal = Terminal::launch_mode(&fixture, false, true);
terminal.wait_for_output(false);
let codex = Pid::from_raw(fixture.invocation().pid as i32);
let start = Instant::now();
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| terminal.cancel(signal)));
let _ = killpg(codex, Signal::SIGKILL);
let status = result.expect("unresponsive Codex must be forcibly cancelled");
assert_eq!(shell_status(status), 128 + signal as i32);
assert!(start.elapsed() < Duration::from_secs(2));
assert_eq!(kill(codex, None), Err(Errno::ESRCH));
}
}
#[test]
fn compact_reports_activity_without_waiting_for_a_message() {
let mut fixture = Fixture::new();
fixture
.environment
.insert("FAKE_CODEX_IDLE_WITHOUT_MESSAGE".into(), "1".into());
let mut terminal = Terminal::launch_mode(&fixture, false, true);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
terminal.drain();
if text(&terminal.output).contains("Codex is working (Ctrl-C to interrupt).") {
break;
}
assert!(
Instant::now() < deadline,
"turn start must be visible before any message"
);
std::thread::sleep(Duration::from_millis(10));
}
assert!(terminal.child.try_wait().unwrap().is_none());
assert!(!text(&terminal.output).contains("live output"));
assert_eq!(shell_status(terminal.cancel(Signal::SIGINT)), 130);
}
#[test]
fn compact_cancels_stopped_agent_and_preserves_first_signal_on_repeat() {
for stopped in [true, false] {
let mut fixture = Fixture::new();
fixture
.environment
.insert("FAKE_CODEX_IGNORE_CANCELLATION".into(), "1".into());
let mut terminal = Terminal::launch_mode(&fixture, false, true);
terminal.wait_for_output(false);
let codex = Pid::from_raw(fixture.invocation().pid as i32);
if stopped {
killpg(codex, Signal::SIGSTOP).unwrap();
}
terminal.master.write_all(&[3]).unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if stopped {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if let Some(status) = terminal.child.try_wait().unwrap() {
terminal.finished = true;
break status;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
} else {
std::thread::sleep(Duration::from_millis(30));
terminal.cancel(Signal::SIGTERM)
}
}));
let _ = killpg(codex, Signal::SIGKILL);
assert_eq!(shell_status(result.unwrap()), 130);
assert_eq!(kill(codex, None), Err(Errno::ESRCH));
}
}