mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
## Why A hook can exit successfully without consuming its JSON input. If it closes stdin while Codex is still writing, the resulting broken pipe should not hide the hook's exit status and output. ## What changed Ignore broken-pipe errors when writing hook stdin and continue waiting for the process result. Other stdin write errors remain failures. ## Testing Add a cross-platform regression test that sends a large input to a fast-exiting hook and verifies that its successful status and stdout are preserved. GitOrigin-RevId: e1b6e7c6ac5e39232e49b45245ea0df9d34d1fe6
216 lines
6.3 KiB
Rust
216 lines
6.3 KiB
Rust
use std::io::ErrorKind;
|
|
use std::path::Path;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::process::Command;
|
|
use tokio::time::timeout;
|
|
use tracing::Span;
|
|
|
|
use super::CommandShell;
|
|
use super::ConfiguredHandler;
|
|
use super::dispatcher::hook_event_name_label;
|
|
use super::dispatcher::hook_execution_mode_label;
|
|
use super::dispatcher::hook_handler_type_label;
|
|
use super::dispatcher::hook_scope_label;
|
|
use super::dispatcher::hook_source_label;
|
|
use super::dispatcher::scope_for_event;
|
|
use codex_protocol::protocol::HookExecutionMode;
|
|
use codex_protocol::protocol::HookHandlerType;
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct CommandRunResult {
|
|
pub started_at: i64,
|
|
pub completed_at: i64,
|
|
pub duration_ms: i64,
|
|
pub exit_code: Option<i32>,
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[tracing::instrument(
|
|
name = "codex.hooks.command",
|
|
level = "trace",
|
|
skip_all,
|
|
fields(
|
|
hook.event_name = hook_event_name_label(handler.event_name),
|
|
hook.handler_type = hook_handler_type_label(HookHandlerType::Command),
|
|
hook.execution_mode = hook_execution_mode_label(HookExecutionMode::Sync),
|
|
hook.scope = hook_scope_label(scope_for_event(handler.event_name)),
|
|
hook.source = hook_source_label(handler.source),
|
|
hook.display_order = handler.display_order,
|
|
hook.configured_order = configured_order,
|
|
hook.timeout_sec = handler.timeout_sec,
|
|
hook.command_outcome = tracing::field::Empty,
|
|
)
|
|
)]
|
|
pub(crate) async fn run_command(
|
|
shell: &CommandShell,
|
|
handler: &ConfiguredHandler,
|
|
configured_order: usize,
|
|
input_json: &str,
|
|
cwd: &Path,
|
|
) -> CommandRunResult {
|
|
let started_at = chrono::Utc::now().timestamp();
|
|
let started = Instant::now();
|
|
|
|
let mut command = build_command(shell, handler);
|
|
command
|
|
.current_dir(cwd)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
|
|
let mut child = match command.spawn() {
|
|
Ok(child) => child,
|
|
Err(err) => {
|
|
return finish_command_run(
|
|
started_at,
|
|
started,
|
|
CommandRunCompletion {
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(err.to_string()),
|
|
outcome: "spawn_error",
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
if let Some(mut stdin) = child.stdin.take()
|
|
&& let Err(err) = stdin.write_all(input_json.as_bytes()).await
|
|
&& err.kind() != ErrorKind::BrokenPipe
|
|
{
|
|
let _ = child.kill().await;
|
|
return finish_command_run(
|
|
started_at,
|
|
started,
|
|
CommandRunCompletion {
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(format!("failed to write hook stdin: {err}")),
|
|
outcome: "stdin_error",
|
|
},
|
|
);
|
|
}
|
|
|
|
let timeout_duration = Duration::from_secs(handler.timeout_sec);
|
|
match timeout(timeout_duration, child.wait_with_output()).await {
|
|
Ok(Ok(output)) => finish_command_run(
|
|
started_at,
|
|
started,
|
|
CommandRunCompletion {
|
|
exit_code: output.status.code(),
|
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
error: None,
|
|
outcome: "completed",
|
|
},
|
|
),
|
|
Ok(Err(err)) => finish_command_run(
|
|
started_at,
|
|
started,
|
|
CommandRunCompletion {
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(err.to_string()),
|
|
outcome: "wait_error",
|
|
},
|
|
),
|
|
Err(_) => finish_command_run(
|
|
started_at,
|
|
started,
|
|
CommandRunCompletion {
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(format!("hook timed out after {}s", handler.timeout_sec)),
|
|
outcome: "timeout",
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
struct CommandRunCompletion {
|
|
exit_code: Option<i32>,
|
|
stdout: String,
|
|
stderr: String,
|
|
error: Option<String>,
|
|
outcome: &'static str,
|
|
}
|
|
|
|
fn finish_command_run(
|
|
started_at: i64,
|
|
started: Instant,
|
|
completion: CommandRunCompletion,
|
|
) -> CommandRunResult {
|
|
Span::current().record("hook.command_outcome", completion.outcome);
|
|
CommandRunResult {
|
|
started_at,
|
|
completed_at: chrono::Utc::now().timestamp(),
|
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
|
exit_code: completion.exit_code,
|
|
stdout: completion.stdout,
|
|
stderr: completion.stderr,
|
|
error: completion.error,
|
|
}
|
|
}
|
|
|
|
fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
|
|
let mut command = if shell.program.is_empty() {
|
|
default_shell_command()
|
|
} else {
|
|
Command::new(&shell.program)
|
|
};
|
|
if shell.program.is_empty() {
|
|
#[cfg(windows)]
|
|
command.raw_arg(format!(r#""{}""#, handler.command));
|
|
|
|
#[cfg(not(windows))]
|
|
command.arg(&handler.command);
|
|
} else {
|
|
command.args(&shell.args);
|
|
|
|
#[cfg(windows)]
|
|
if shell.args.iter().any(|arg| arg.eq_ignore_ascii_case("/c")) {
|
|
command.raw_arg(format!(r#""{}""#, handler.command));
|
|
} else {
|
|
command.arg(&handler.command);
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
command.arg(&handler.command);
|
|
}
|
|
command.envs(&handler.env);
|
|
command
|
|
}
|
|
|
|
fn default_shell_command() -> Command {
|
|
#[cfg(windows)]
|
|
{
|
|
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
|
|
let mut command = Command::new(comspec);
|
|
command.arg("/C");
|
|
command
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
|
|
let mut command = Command::new(shell);
|
|
command.arg("-lc");
|
|
command
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "command_runner_tests.rs"]
|
|
mod tests;
|