mirror of
https://github.com/openai/codex.git
synced 2026-05-15 16:53:05 +00:00
## Why Plugins can bundle lifecycle hooks, but Codex previously only discovered hooks from user, project, and managed config layers. This adds the plugin discovery and runtime plumbing needed for plugin-bundled hooks while keeping execution behind the `plugin_hooks` feature flag. ## What - Discovers plugin hook sources from each plugin's default `hooks/hooks.json`. - Supports `plugin.json` manifest `hooks` entries as either relative paths or inline hook objects. - Plumbs discovered plugin hook sources through plugin loading into the hook runtime when `plugin_hooks` is enabled. - Marks plugin-originated hook runs as `HookSource::Plugin`. - Injects `PLUGIN_ROOT` and `CLAUDE_PLUGIN_ROOT` into plugin hook command environments. - Updates generated schemas and hook source metadata for the plugin hook source. ## Stack 1. This PR - openai/codex#19705 2. openai/codex#19778 3. openai/codex#19840 4. openai/codex#19882 ## Reviewer Notes - Core logic is in `codex-rs/core-plugins/src/loader.rs` and `codex-rs/hooks/src/engine/discovery.rs` - Moved existing / adding new tests to `codex-rs/core-plugins/src/loader_tests.rs` hence the large diff there - Otherwise mostly plumbing and minor schema updates ### Core Changes The `codex-rs/core` changes are limited to wiring plugin hook support into existing core flows: - `core/src/session/session.rs` conditionally pulls effective plugin hook sources and plugin hook load warnings from `PluginsManager` when `plugin_hooks` is enabled, then passes them into `HooksConfig`. - `core/src/hook_runtime.rs` adds the `plugin` metric tag for `HookSource::Plugin`. - `core/config.schema.json` picks up the new `plugin_hooks` feature flag, and `core/src/plugins/manager_tests.rs` updates fixtures for the added plugin hook fields. --------- Co-authored-by: Codex <noreply@openai.com>
136 lines
4.2 KiB
Rust
136 lines
4.2 KiB
Rust
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 super::CommandShell;
|
|
use super::ConfiguredHandler;
|
|
|
|
#[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>,
|
|
}
|
|
|
|
pub(crate) async fn run_command(
|
|
shell: &CommandShell,
|
|
handler: &ConfiguredHandler,
|
|
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 CommandRunResult {
|
|
started_at,
|
|
completed_at: chrono::Utc::now().timestamp(),
|
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(err.to_string()),
|
|
};
|
|
}
|
|
};
|
|
|
|
if let Some(mut stdin) = child.stdin.take()
|
|
&& let Err(err) = stdin.write_all(input_json.as_bytes()).await
|
|
{
|
|
let _ = child.kill().await;
|
|
return CommandRunResult {
|
|
started_at,
|
|
completed_at: chrono::Utc::now().timestamp(),
|
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(format!("failed to write hook stdin: {err}")),
|
|
};
|
|
}
|
|
|
|
let timeout_duration = Duration::from_secs(handler.timeout_sec);
|
|
match timeout(timeout_duration, child.wait_with_output()).await {
|
|
Ok(Ok(output)) => CommandRunResult {
|
|
started_at,
|
|
completed_at: chrono::Utc::now().timestamp(),
|
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
|
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,
|
|
},
|
|
Ok(Err(err)) => CommandRunResult {
|
|
started_at,
|
|
completed_at: chrono::Utc::now().timestamp(),
|
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(err.to_string()),
|
|
},
|
|
Err(_) => CommandRunResult {
|
|
started_at,
|
|
completed_at: chrono::Utc::now().timestamp(),
|
|
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
error: Some(format!("hook timed out after {}s", handler.timeout_sec)),
|
|
},
|
|
}
|
|
}
|
|
|
|
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() {
|
|
command.arg(&handler.command);
|
|
} else {
|
|
command.args(&shell.args);
|
|
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
|
|
}
|
|
}
|