mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Discover hooks bundled with plugins (#19705)
## 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>
This commit is contained in:
committed by
GitHub
Unverified
parent
89698ad1c3
commit
c6e7d564c3
Generated
+2
@@ -2780,6 +2780,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"codex-config",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"futures",
|
||||
@@ -3151,6 +3152,7 @@ dependencies = [
|
||||
name = "codex-plugin"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-config",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-plugins",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -684,6 +684,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str {
|
||||
HookSource::Project => "project",
|
||||
HookSource::Mdm => "mdm",
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
HookSource::Unknown => "unknown",
|
||||
|
||||
@@ -1900,6 +1900,7 @@
|
||||
"project",
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
+1
@@ -9680,6 +9680,7 @@
|
||||
"project",
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
+1
@@ -6310,6 +6310,7 @@
|
||||
"project",
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"project",
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"project",
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
|
||||
@@ -469,6 +469,7 @@ v2_enum_from_core!(
|
||||
Project,
|
||||
Mdm,
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
Unknown,
|
||||
|
||||
+133
-152
@@ -1,4 +1,5 @@
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::manifest::PluginManifestHooks;
|
||||
use crate::manifest::PluginManifestPaths;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
@@ -7,6 +8,7 @@ use crate::marketplace::load_marketplace;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::plugin_version_for_source;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::HooksFile;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_core_skills::SkillMetadata;
|
||||
@@ -19,6 +21,7 @@ use codex_exec_server::LOCAL_FS;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::LoadedPlugin;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginLoadOutcome;
|
||||
@@ -26,6 +29,7 @@ use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::find_plugin_manifest_path;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -39,6 +43,7 @@ use tempfile::TempDir;
|
||||
use tracing::warn;
|
||||
|
||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
const DEFAULT_HOOKS_CONFIG_FILE: &str = "hooks/hooks.json";
|
||||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
@@ -477,6 +482,8 @@ async fn load_plugin(
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
hook_sources: Vec::new(),
|
||||
hook_load_warnings: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
|
||||
@@ -484,14 +491,14 @@ async fn load_plugin(
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let plugin_root = match plugin_id {
|
||||
Ok(_) => match active_plugin_root {
|
||||
Some(plugin_root) => plugin_root,
|
||||
None => {
|
||||
let (loaded_plugin_id, plugin_root) = match plugin_id {
|
||||
Ok(plugin_id) => {
|
||||
let Some(plugin_root) = active_plugin_root else {
|
||||
loaded_plugin.error = Some("plugin is not installed".to_string());
|
||||
return loaded_plugin;
|
||||
}
|
||||
},
|
||||
};
|
||||
(plugin_id, plugin_root)
|
||||
}
|
||||
Err(err) => {
|
||||
loaded_plugin.error = Some(err.to_string());
|
||||
return loaded_plugin;
|
||||
@@ -545,6 +552,14 @@ async fn load_plugin(
|
||||
}
|
||||
loaded_plugin.mcp_servers = mcp_servers;
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
|
||||
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
|
||||
&plugin_root,
|
||||
&loaded_plugin_id,
|
||||
&store.plugin_data_root(&loaded_plugin_id),
|
||||
manifest_paths,
|
||||
);
|
||||
loaded_plugin.hook_sources = hook_sources;
|
||||
loaded_plugin.hook_load_warnings = hook_load_warnings;
|
||||
loaded_plugin
|
||||
}
|
||||
|
||||
@@ -674,6 +689,116 @@ fn default_app_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
paths
|
||||
}
|
||||
|
||||
// Discover plugin-bundled hooks from manifest `hooks` entries when present
|
||||
// (path, paths, inline object, or inline objects), otherwise from the default
|
||||
// `hooks/hooks.json` file.
|
||||
pub fn load_plugin_hooks(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
plugin_id: &PluginId,
|
||||
plugin_data_root: &AbsolutePathBuf,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
) -> (Vec<PluginHookSource>, Vec<String>) {
|
||||
let mut sources = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
match &manifest_paths.hooks {
|
||||
Some(PluginManifestHooks::Paths(paths)) => {
|
||||
for path in paths {
|
||||
append_plugin_hook_file(
|
||||
plugin_root,
|
||||
plugin_id,
|
||||
plugin_data_root,
|
||||
path,
|
||||
&mut sources,
|
||||
&mut warnings,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(PluginManifestHooks::Inline(hooks_files)) => {
|
||||
let manifest_path = find_plugin_manifest_path(plugin_root.as_path())
|
||||
.and_then(|path| AbsolutePathBuf::try_from(path).ok())
|
||||
.unwrap_or_else(|| plugin_root.join(".codex-plugin/plugin.json"));
|
||||
for (index, hooks_file) in hooks_files.iter().enumerate() {
|
||||
if hooks_file.hooks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
sources.push(PluginHookSource {
|
||||
plugin_id: plugin_id.clone(),
|
||||
plugin_root: plugin_root.clone(),
|
||||
plugin_data_root: plugin_data_root.clone(),
|
||||
source_path: manifest_path.clone(),
|
||||
source_relative_path: format!("plugin.json#hooks[{index}]"),
|
||||
hooks: hooks_file.hooks.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let default_path = plugin_root.join(DEFAULT_HOOKS_CONFIG_FILE);
|
||||
if default_path.as_path().is_file() {
|
||||
append_plugin_hook_file(
|
||||
plugin_root,
|
||||
plugin_id,
|
||||
plugin_data_root,
|
||||
&default_path,
|
||||
&mut sources,
|
||||
&mut warnings,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
(sources, warnings)
|
||||
}
|
||||
|
||||
// Append one resolved plugin hook file, keeping source metadata for runtime
|
||||
// reporting and collecting load warnings for startup surfacing.
|
||||
fn append_plugin_hook_file(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
plugin_id: &PluginId,
|
||||
plugin_data_root: &AbsolutePathBuf,
|
||||
path: &AbsolutePathBuf,
|
||||
sources: &mut Vec<PluginHookSource>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let contents = match fs::read_to_string(path.as_path()) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to read plugin hooks config {}: {err}",
|
||||
path.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parsed = match serde_json::from_str::<HooksFile>(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warnings.push(format!(
|
||||
"failed to parse plugin hooks config {}: {err}",
|
||||
path.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if parsed.hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let source_relative_path = path
|
||||
.as_path()
|
||||
.strip_prefix(plugin_root.as_path())
|
||||
.unwrap_or(path.as_path())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
sources.push(PluginHookSource {
|
||||
plugin_id: plugin_id.clone(),
|
||||
plugin_root: plugin_root.clone(),
|
||||
plugin_data_root: plugin_data_root.clone(),
|
||||
source_path: path.clone(),
|
||||
source_relative_path,
|
||||
hooks: parsed.hooks,
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_apps_from_paths(
|
||||
plugin_root: &Path,
|
||||
app_config_paths: Vec<AbsolutePathBuf>,
|
||||
@@ -1014,149 +1139,5 @@ fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_file_supports_mcp_servers_object_format() {
|
||||
let parsed = serde_json::from_str::<PluginMcpFile>(
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"command": "sample-mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse wrapped plugin mcp config")
|
||||
.into_mcp_servers();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HashMap::from([(
|
||||
"sample".to_string(),
|
||||
serde_json::json!({
|
||||
"command": "sample-mcp"
|
||||
}),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() {
|
||||
let parsed = serde_json::from_str::<PluginMcpFile>(
|
||||
r#"{
|
||||
"$schema": "https://example.com/plugin-mcp.schema.json",
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"command": "sample-mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse plugin mcp config with metadata")
|
||||
.into_mcp_servers();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HashMap::from([(
|
||||
"sample".to_string(),
|
||||
serde_json::json!({
|
||||
"command": "sample-mcp"
|
||||
}),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_file_supports_top_level_server_map_format() {
|
||||
let parsed = serde_json::from_str::<PluginMcpFile>(
|
||||
r#"{
|
||||
"linear": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse flat plugin mcp config")
|
||||
.into_mcp_servers();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HashMap::from([(
|
||||
"linear".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_plugin_cache_version_shortens_full_git_sha() {
|
||||
assert_eq!(
|
||||
curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"),
|
||||
"01234567"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_plugin_cache_version_preserves_non_git_sha_versions() {
|
||||
assert_eq!(
|
||||
curated_plugin_cache_version("export-backup"),
|
||||
"export-backup"
|
||||
);
|
||||
assert_eq!(curated_plugin_cache_version("0123456"), "0123456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_git_subdir_uses_sparse_checkout() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let repo = tempfile::tempdir().expect("create git repo");
|
||||
let plugin_dir = repo.path().join("plugins/toolkit");
|
||||
fs::create_dir_all(&plugin_dir).expect("create plugin directory");
|
||||
fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin");
|
||||
fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker");
|
||||
fs::write(repo.path().join("plugins/other/marker.txt"), "other")
|
||||
.expect("write other marker");
|
||||
fs::write(repo.path().join("root.txt"), "root").expect("write root marker");
|
||||
|
||||
run_git(&["init"], Some(repo.path())).expect("init git repo");
|
||||
run_git(
|
||||
&["config", "user.email", "test@example.com"],
|
||||
Some(repo.path()),
|
||||
)
|
||||
.expect("configure git email");
|
||||
run_git(&["config", "user.name", "Test User"], Some(repo.path()))
|
||||
.expect("configure git name");
|
||||
run_git(&["add", "."], Some(repo.path())).expect("stage git repo");
|
||||
run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo");
|
||||
|
||||
let materialized = materialize_marketplace_plugin_source(
|
||||
codex_home.path(),
|
||||
&MarketplacePluginSource::Git {
|
||||
url: repo.path().display().to_string(),
|
||||
path: Some("plugins/toolkit".to_string()),
|
||||
ref_name: None,
|
||||
sha: None,
|
||||
},
|
||||
)
|
||||
.expect("materialize git source");
|
||||
|
||||
assert_eq!(
|
||||
plugin_dir.file_name(),
|
||||
materialized.path.as_path().file_name()
|
||||
);
|
||||
assert!(materialized.path.as_path().join("marker.txt").is_file());
|
||||
let checkout_root = materialized
|
||||
.path
|
||||
.as_path()
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("materialized path should be nested under checkout root");
|
||||
assert!(!checkout_root.join("root.txt").exists());
|
||||
assert!(!checkout_root.join("plugins/other/marker.txt").exists());
|
||||
}
|
||||
}
|
||||
#[path = "loader_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
use super::*;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use codex_plugin::PluginId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_file_supports_mcp_servers_object_format() {
|
||||
let parsed = serde_json::from_str::<PluginMcpFile>(
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"command": "sample-mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse wrapped plugin mcp config")
|
||||
.into_mcp_servers();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HashMap::from([(
|
||||
"sample".to_string(),
|
||||
serde_json::json!({
|
||||
"command": "sample-mcp"
|
||||
}),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() {
|
||||
let parsed = serde_json::from_str::<PluginMcpFile>(
|
||||
r#"{
|
||||
"$schema": "https://example.com/plugin-mcp.schema.json",
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"command": "sample-mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse plugin mcp config with metadata")
|
||||
.into_mcp_servers();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HashMap::from([(
|
||||
"sample".to_string(),
|
||||
serde_json::json!({
|
||||
"command": "sample-mcp"
|
||||
}),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_file_supports_top_level_server_map_format() {
|
||||
let parsed = serde_json::from_str::<PluginMcpFile>(
|
||||
r#"{
|
||||
"linear": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse flat plugin mcp config")
|
||||
.into_mcp_servers();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HashMap::from([(
|
||||
"linear".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_plugin_cache_version_shortens_full_git_sha() {
|
||||
assert_eq!(
|
||||
curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"),
|
||||
"01234567"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_plugin_cache_version_preserves_non_git_sha_versions() {
|
||||
assert_eq!(
|
||||
curated_plugin_cache_version("export-backup"),
|
||||
"export-backup"
|
||||
);
|
||||
assert_eq!(curated_plugin_cache_version("0123456"), "0123456");
|
||||
}
|
||||
|
||||
fn plugin_id() -> PluginId {
|
||||
PluginId::parse("demo-plugin@test-marketplace").expect("plugin id")
|
||||
}
|
||||
|
||||
fn plugin_root() -> (tempfile::TempDir, AbsolutePathBuf) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let plugin_root =
|
||||
AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
|
||||
fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir");
|
||||
(tmp, plugin_root)
|
||||
}
|
||||
|
||||
fn write_manifest(plugin_root: &AbsolutePathBuf, manifest: &str) {
|
||||
fs::write(plugin_root.join(".codex-plugin/plugin.json"), manifest).expect("write manifest");
|
||||
}
|
||||
|
||||
fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &str, command: &str) {
|
||||
fs::write(
|
||||
plugin_root.join(relative_path),
|
||||
format!(
|
||||
r#"{{
|
||||
"hooks": {{
|
||||
"{event}": [
|
||||
{{
|
||||
"hooks": [{{ "type": "command", "command": "{command}" }}]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write hooks");
|
||||
}
|
||||
|
||||
fn load_sources(plugin_root: &AbsolutePathBuf) -> (Vec<PluginHookSource>, Vec<String>) {
|
||||
let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest");
|
||||
let plugin_data_root = AbsolutePathBuf::try_from(
|
||||
plugin_root
|
||||
.as_path()
|
||||
.parent()
|
||||
.expect("plugin root parent")
|
||||
.join("plugin-data"),
|
||||
)
|
||||
.expect("plugin data root");
|
||||
load_plugin_hooks(
|
||||
plugin_root,
|
||||
&plugin_id(),
|
||||
&plugin_data_root,
|
||||
&manifest.paths,
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_sources(sources: &[PluginHookSource], expected_relative_paths: &[&str]) {
|
||||
assert_eq!(
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| source.plugin_id.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![plugin_id(); expected_relative_paths.len()]
|
||||
);
|
||||
assert_eq!(
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| source.source_relative_path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
expected_relative_paths
|
||||
);
|
||||
assert_eq!(
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| source.hooks.handler_count())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1; expected_relative_paths.len()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_hooks_discovers_default_hooks_file() {
|
||||
let (_tmp, plugin_root) = plugin_root();
|
||||
write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#);
|
||||
fs::write(
|
||||
plugin_root.join("hooks/hooks.json"),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{ "type": "command", "command": "echo default" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write hooks");
|
||||
|
||||
let (sources, warnings) = load_sources(&plugin_root);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_sources(&sources, &["hooks/hooks.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_hooks_supports_manifest_hook_path() {
|
||||
let (_tmp, plugin_root) = plugin_root();
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"hooks": "./hooks/one.json"
|
||||
}"#,
|
||||
);
|
||||
write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one");
|
||||
|
||||
let (sources, warnings) = load_sources(&plugin_root);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_sources(&sources, &["hooks/one.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() {
|
||||
let (_tmp, plugin_root) = plugin_root();
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"hooks": ["./hooks/one.json", "./hooks/two.json"]
|
||||
}"#,
|
||||
);
|
||||
write_hook_file(
|
||||
&plugin_root,
|
||||
"hooks/hooks.json",
|
||||
"PreToolUse",
|
||||
"echo ignored",
|
||||
);
|
||||
write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one");
|
||||
write_hook_file(&plugin_root, "hooks/two.json", "PostToolUse", "echo two");
|
||||
|
||||
let (sources, warnings) = load_sources(&plugin_root);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_sources(&sources, &["hooks/one.json", "hooks/two.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_hooks_supports_inline_manifest_hooks() {
|
||||
let (_tmp, plugin_root) = plugin_root();
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"hooks": {
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"hooks": [{ "type": "command", "command": "echo inline" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
|
||||
let (sources, warnings) = load_sources(&plugin_root);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_sources(&sources, &["plugin.json#hooks[0]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_hooks_reports_invalid_hook_file() {
|
||||
let (_tmp, plugin_root) = plugin_root();
|
||||
write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#);
|
||||
fs::write(plugin_root.join("hooks/hooks.json"), "{ not-json").expect("write invalid hooks");
|
||||
|
||||
let (sources, warnings) = load_sources(&plugin_root);
|
||||
|
||||
assert_eq!(sources, Vec::<PluginHookSource>::new());
|
||||
assert_eq!(
|
||||
warnings,
|
||||
vec![format!(
|
||||
"failed to parse plugin hooks config {}: key must be a string at line 1 column 3",
|
||||
plugin_root.join("hooks/hooks.json").display()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_hooks_supports_inline_manifest_hook_list() {
|
||||
let (_tmp, plugin_root) = plugin_root();
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"hooks": [
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [{ "type": "command", "command": "echo inline one" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [{ "type": "command", "command": "echo inline two" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
let (sources, warnings) = load_sources(&plugin_root);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_sources(&sources, &["plugin.json#hooks[0]", "plugin.json#hooks[1]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_git_subdir_uses_sparse_checkout() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let repo = tempfile::tempdir().expect("create git repo");
|
||||
let plugin_dir = repo.path().join("plugins/toolkit");
|
||||
fs::create_dir_all(&plugin_dir).expect("create plugin directory");
|
||||
fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin");
|
||||
fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker");
|
||||
fs::write(repo.path().join("plugins/other/marker.txt"), "other").expect("write other marker");
|
||||
fs::write(repo.path().join("root.txt"), "root").expect("write root marker");
|
||||
|
||||
run_git(&["init"], Some(repo.path())).expect("init git repo");
|
||||
run_git(
|
||||
&["config", "user.email", "test@example.com"],
|
||||
Some(repo.path()),
|
||||
)
|
||||
.expect("configure git email");
|
||||
run_git(&["config", "user.name", "Test User"], Some(repo.path())).expect("configure git name");
|
||||
run_git(&["add", "."], Some(repo.path())).expect("stage git repo");
|
||||
run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo");
|
||||
|
||||
let materialized = materialize_marketplace_plugin_source(
|
||||
codex_home.path(),
|
||||
&MarketplacePluginSource::Git {
|
||||
url: repo.path().display().to_string(),
|
||||
path: Some("plugins/toolkit".to_string()),
|
||||
ref_name: None,
|
||||
sha: None,
|
||||
},
|
||||
)
|
||||
.expect("materialize git source");
|
||||
|
||||
assert_eq!(
|
||||
plugin_dir.file_name(),
|
||||
materialized.path.as_path().file_name()
|
||||
);
|
||||
assert!(materialized.path.as_path().join("marker.txt").is_file());
|
||||
let checkout_root = materialized
|
||||
.path
|
||||
.as_path()
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("materialized path should be nested under checkout root");
|
||||
assert!(!checkout_root.join("root.txt").exists());
|
||||
assert!(!checkout_root.join("plugins/other/marker.txt").exists());
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_config::HooksFile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::find_plugin_manifest_path;
|
||||
use serde::Deserialize;
|
||||
@@ -26,6 +27,8 @@ struct RawPluginManifest {
|
||||
#[serde(default)]
|
||||
apps: Option<String>,
|
||||
#[serde(default)]
|
||||
hooks: Option<RawPluginManifestHooks>,
|
||||
#[serde(default)]
|
||||
interface: Option<RawPluginManifestInterface>,
|
||||
}
|
||||
|
||||
@@ -43,6 +46,13 @@ pub struct PluginManifestPaths {
|
||||
pub skills: Option<AbsolutePathBuf>,
|
||||
pub mcp_servers: Option<AbsolutePathBuf>,
|
||||
pub apps: Option<AbsolutePathBuf>,
|
||||
pub hooks: Option<PluginManifestHooks>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginManifestHooks {
|
||||
Paths(Vec<AbsolutePathBuf>),
|
||||
Inline(Vec<HooksFile>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -114,6 +124,16 @@ enum RawPluginManifestDefaultPromptEntry {
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawPluginManifestHooks {
|
||||
Path(String),
|
||||
Paths(Vec<String>),
|
||||
Inline(HooksFile),
|
||||
InlineList(Vec<HooksFile>),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
let manifest_path = find_plugin_manifest_path(plugin_root)?;
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
@@ -126,6 +146,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
skills,
|
||||
mcp_servers,
|
||||
apps,
|
||||
hooks,
|
||||
interface,
|
||||
} = manifest;
|
||||
let name = plugin_root
|
||||
@@ -219,6 +240,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
mcp_servers.as_deref(),
|
||||
),
|
||||
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
|
||||
hooks: resolve_manifest_hooks(plugin_root, hooks),
|
||||
},
|
||||
interface,
|
||||
})
|
||||
@@ -233,6 +255,36 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_manifest_hooks(
|
||||
plugin_root: &Path,
|
||||
hooks: Option<RawPluginManifestHooks>,
|
||||
) -> Option<PluginManifestHooks> {
|
||||
match hooks? {
|
||||
RawPluginManifestHooks::Path(path) => {
|
||||
resolve_manifest_path(plugin_root, "hooks", Some(&path))
|
||||
.map(|path| PluginManifestHooks::Paths(vec![path]))
|
||||
}
|
||||
RawPluginManifestHooks::Paths(paths) => {
|
||||
let hooks = paths
|
||||
.iter()
|
||||
.filter_map(|path| resolve_manifest_path(plugin_root, "hooks", Some(path)))
|
||||
.collect::<Vec<_>>();
|
||||
(!hooks.is_empty()).then_some(PluginManifestHooks::Paths(hooks))
|
||||
}
|
||||
RawPluginManifestHooks::Inline(hooks) => Some(PluginManifestHooks::Inline(vec![hooks])),
|
||||
RawPluginManifestHooks::InlineList(hooks) => {
|
||||
(!hooks.is_empty()).then_some(PluginManifestHooks::Inline(hooks))
|
||||
}
|
||||
RawPluginManifestHooks::Invalid(value) => {
|
||||
tracing::warn!(
|
||||
"ignoring hooks: expected a string, string array, object, or object array; found {}",
|
||||
json_value_type(&value)
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_interface_asset_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::path::PathBuf;
|
||||
|
||||
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
pub const PLUGINS_DATA_DIR: &str = "plugins/data";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallResult {
|
||||
@@ -24,6 +25,7 @@ pub struct PluginInstallResult {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginStore {
|
||||
root: AbsolutePathBuf,
|
||||
data_root: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl PluginStore {
|
||||
@@ -35,8 +37,11 @@ impl PluginStore {
|
||||
pub fn try_new(codex_home: PathBuf) -> Result<Self, PluginStoreError> {
|
||||
let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR))
|
||||
.map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?;
|
||||
let data_root =
|
||||
AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))
|
||||
.map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?;
|
||||
|
||||
Ok(Self { root })
|
||||
Ok(Self { root, data_root })
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &AbsolutePathBuf {
|
||||
@@ -53,6 +58,13 @@ impl PluginStore {
|
||||
self.plugin_base_root(plugin_id).join(plugin_version)
|
||||
}
|
||||
|
||||
pub fn plugin_data_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||
self.data_root.join(format!(
|
||||
"{}-{}",
|
||||
plugin_id.plugin_name, plugin_id.marketplace_name
|
||||
))
|
||||
}
|
||||
|
||||
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
|
||||
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
|
||||
.ok()?
|
||||
|
||||
@@ -109,6 +109,18 @@ fn plugin_root_derives_path_from_key_and_version() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_data_root_derives_path_from_key() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.plugin_data_root(&plugin_id).as_path(),
|
||||
tmp.path().join("plugins/data/sample-debug")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_with_version_uses_requested_cache_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -463,6 +463,9 @@
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugin_hooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -3379,6 +3382,9 @@
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugin_hooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -473,6 +473,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookSource::Project => "project",
|
||||
HookSource::Mdm => "mdm",
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
HookSource::Unknown => "unknown",
|
||||
|
||||
@@ -219,6 +219,8 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
},
|
||||
)]),
|
||||
apps: vec![AppConnectorId("connector_example".to_string())],
|
||||
hook_sources: Vec::new(),
|
||||
hook_load_warnings: Vec::new(),
|
||||
error: None,
|
||||
}]
|
||||
);
|
||||
@@ -719,6 +721,8 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
hook_sources: Vec::new(),
|
||||
hook_load_warnings: Vec::new(),
|
||||
error: None,
|
||||
}]
|
||||
);
|
||||
@@ -836,6 +840,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
hook_sources: Vec::new(),
|
||||
hook_load_warnings: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
|
||||
|
||||
@@ -750,10 +750,22 @@ impl Session {
|
||||
default_shell.derive_exec_args("", /*use_login_shell*/ false);
|
||||
let hook_shell_program = hook_shell_argv.remove(0);
|
||||
let _ = hook_shell_argv.pop();
|
||||
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
|
||||
let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled {
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
|
||||
(
|
||||
plugin_outcome.effective_plugin_hook_sources(),
|
||||
plugin_outcome.effective_plugin_hook_warnings(),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
feature_enabled: config.features.enabled(Feature::CodexHooks),
|
||||
config_layer_stack: Some(config.config_layer_stack.clone()),
|
||||
plugin_hook_sources,
|
||||
plugin_hook_load_warnings,
|
||||
shell_program: Some(hook_shell_program),
|
||||
shell_args: hook_shell_argv,
|
||||
});
|
||||
|
||||
@@ -727,7 +727,7 @@ fn request_message_input_texts(body: &[u8], role: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -841,7 +841,7 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -886,7 +886,7 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -962,7 +962,7 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1028,7 +1028,7 @@ async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1111,7 +1111,7 @@ async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1276,7 +1276,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn permission_request_hook_allows_shell_command_without_user_approval() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1355,7 +1355,7 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1437,7 +1437,7 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1518,7 +1518,7 @@ async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn permission_request_hook_allows_network_approval_without_prompt() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1649,7 +1649,7 @@ allow_local_binding = true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1719,7 +1719,7 @@ async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1821,7 +1821,151 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "plugin-pretooluse-shell-command";
|
||||
let marker = std::env::temp_dir().join("plugin-pretooluse-shell-command-marker");
|
||||
let command = format!("printf blocked > {}", marker.display());
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "plugin hook blocked it"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
||||
let hooks_dir = plugin_root.join("hooks");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin"))
|
||||
.context("create plugin manifest directory")?;
|
||||
fs::create_dir_all(&hooks_dir).context("create plugin hooks directory")?;
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.context("write plugin manifest")?;
|
||||
fs::write(
|
||||
home.path().join("config.toml"),
|
||||
r#"[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.context("write plugin config")?;
|
||||
|
||||
let script_path = hooks_dir.join("pre_tool_use_hook.py");
|
||||
let log_path = hooks_dir.join("pre_tool_use_hook_log.jsonl");
|
||||
fs::write(
|
||||
&script_path,
|
||||
format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
print(json.dumps({{
|
||||
"hookSpecificOutput": {{
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "blocked by plugin hook"
|
||||
}}
|
||||
}}))
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
),
|
||||
)
|
||||
.context("write plugin pre tool use hook script")?;
|
||||
fs::write(
|
||||
hooks_dir.join("hooks.json"),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "^Bash$",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "python3 ${PLUGIN_ROOT}/hooks/pre_tool_use_hook.py"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.context("write plugin hooks config")?;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Plugins)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::PluginHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover plugin pre tool use marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn_with_policy(
|
||||
"run the shell command blocked by a plugin hook",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert!(
|
||||
output.contains("Command blocked by PreToolUse hook: blocked by plugin hook"),
|
||||
"blocked tool output should surface the plugin hook reason",
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"plugin hook should block shell command execution"
|
||||
);
|
||||
|
||||
let hook_inputs = read_hook_inputs_from_log(&log_path)?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
||||
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1904,7 +2048,7 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2005,7 +2149,7 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2099,7 +2243,7 @@ async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2186,7 +2330,7 @@ async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2263,7 +2407,7 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2338,7 +2482,7 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2410,7 +2554,7 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_records_additional_context_for_shell_command() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2507,7 +2651,7 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2575,7 +2719,7 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_reason() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -2644,7 +2788,7 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2718,7 +2862,7 @@ async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -2793,7 +2937,7 @@ async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedb
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_windows!(Ok(()));
|
||||
@@ -2899,7 +3043,7 @@ async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -2990,7 +3134,7 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -3063,7 +3207,7 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ pub enum Feature {
|
||||
ToolSuggest,
|
||||
/// Enable plugins.
|
||||
Plugins,
|
||||
/// Enable plugin-bundled lifecycle hooks.
|
||||
PluginHooks,
|
||||
/// Allow the in-app browser pane in desktop apps.
|
||||
///
|
||||
/// Requirements-only gate: this should be set from requirements, not user config.
|
||||
@@ -876,6 +878,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::PluginHooks,
|
||||
key: "plugin_hooks",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::InAppBrowser,
|
||||
key: "in_app_browser",
|
||||
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-config = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
futures = { workspace = true, features = ["alloc"] }
|
||||
|
||||
@@ -108,12 +108,12 @@ fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
|
||||
};
|
||||
if shell.program.is_empty() {
|
||||
command.arg(&handler.command);
|
||||
command
|
||||
} else {
|
||||
command.args(&shell.args);
|
||||
command.arg(&handler.command);
|
||||
command
|
||||
}
|
||||
command.envs(&handler.env);
|
||||
command
|
||||
}
|
||||
|
||||
fn default_shell_command() -> Command {
|
||||
|
||||
@@ -12,8 +12,10 @@ use codex_config::HooksFile;
|
||||
use codex_config::ManagedHooksRequirementsToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
@@ -25,23 +27,34 @@ pub(crate) struct DiscoveryResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
struct HookHandlerSource<'a> {
|
||||
path: &'a AbsolutePathBuf,
|
||||
is_managed: bool,
|
||||
source: HookSource,
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
|
||||
pub(crate) fn discover_handlers(
|
||||
config_layer_stack: Option<&ConfigLayerStack>,
|
||||
plugin_hook_sources: Vec<PluginHookSource>,
|
||||
plugin_hook_load_warnings: Vec<String>,
|
||||
) -> DiscoveryResult {
|
||||
let Some(config_layer_stack) = config_layer_stack else {
|
||||
return DiscoveryResult {
|
||||
handlers: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = plugin_hook_load_warnings;
|
||||
let mut display_order = 0_i64;
|
||||
append_plugin_hook_sources(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
plugin_hook_sources,
|
||||
);
|
||||
return DiscoveryResult { handlers, warnings };
|
||||
};
|
||||
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut warnings = plugin_hook_load_warnings;
|
||||
let mut display_order = 0_i64;
|
||||
|
||||
append_managed_requirement_handlers(
|
||||
@@ -80,6 +93,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
source: hook_source,
|
||||
env: HashMap::new(),
|
||||
},
|
||||
hook_events,
|
||||
);
|
||||
@@ -94,12 +108,20 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
source: hook_source,
|
||||
env: HashMap::new(),
|
||||
},
|
||||
hook_events,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
append_plugin_hook_sources(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
plugin_hook_sources,
|
||||
);
|
||||
|
||||
DiscoveryResult { handlers, warnings }
|
||||
}
|
||||
|
||||
@@ -125,11 +147,51 @@ fn append_managed_requirement_handlers(
|
||||
path: &source_path,
|
||||
is_managed: true,
|
||||
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
|
||||
env: HashMap::new(),
|
||||
},
|
||||
managed_hooks.get().hooks.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
fn append_plugin_hook_sources(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
plugin_hook_sources: Vec<PluginHookSource>,
|
||||
) {
|
||||
// TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable.
|
||||
for source in plugin_hook_sources {
|
||||
let PluginHookSource {
|
||||
plugin_root,
|
||||
plugin_data_root,
|
||||
source_path,
|
||||
hooks,
|
||||
..
|
||||
} = source;
|
||||
let mut env = HashMap::new();
|
||||
let plugin_root_value = plugin_root.display().to_string();
|
||||
let plugin_data_root_value = plugin_data_root.display().to_string();
|
||||
env.insert("PLUGIN_ROOT".to_string(), plugin_root_value.clone());
|
||||
// For OOTB compat with existing plugins that use this env var.
|
||||
env.insert("CLAUDE_PLUGIN_ROOT".to_string(), plugin_root_value);
|
||||
env.insert("PLUGIN_DATA".to_string(), plugin_data_root_value.clone());
|
||||
// For OOTB compat with existing plugins that use this env var.
|
||||
env.insert("CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root_value);
|
||||
append_hook_events(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
is_managed: false,
|
||||
source: HookSource::Plugin,
|
||||
env,
|
||||
},
|
||||
hooks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_hooks_source_path(
|
||||
managed_hooks: &ManagedHooksRequirementsToml,
|
||||
requirement_source: Option<&RequirementSource>,
|
||||
@@ -278,7 +340,7 @@ fn append_hook_events(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
source,
|
||||
source.clone(),
|
||||
event_name,
|
||||
groups,
|
||||
);
|
||||
@@ -298,7 +360,7 @@ fn append_matcher_groups(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
source,
|
||||
source.clone(),
|
||||
event_name,
|
||||
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
|
||||
group.hooks,
|
||||
@@ -347,6 +409,9 @@ fn append_group_handlers(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let command = source.env.iter().fold(command, |command, (key, value)| {
|
||||
command.replace(&format!("${{{key}}}"), value)
|
||||
});
|
||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
@@ -358,6 +423,7 @@ fn append_group_handlers(
|
||||
source_path: source.path.clone(),
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
env: source.env.clone(),
|
||||
});
|
||||
*display_order += 1;
|
||||
}
|
||||
@@ -431,6 +497,7 @@ mod tests {
|
||||
path,
|
||||
is_managed: false,
|
||||
source: hook_source(),
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +542,7 @@ mod tests {
|
||||
source_path: source_path.clone(),
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -508,6 +576,7 @@ mod tests {
|
||||
source_path: source_path.clone(),
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ mod tests {
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: HookSource::User,
|
||||
display_order,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ pub(crate) mod dispatcher;
|
||||
pub(crate) mod output_parser;
|
||||
pub(crate) mod schema_loader;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -39,6 +42,7 @@ pub(crate) struct ConfiguredHandler {
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub source: HookSource,
|
||||
pub display_order: i64,
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ConfiguredHandler {
|
||||
@@ -74,6 +78,8 @@ impl ClaudeHooksEngine {
|
||||
pub(crate) fn new(
|
||||
enabled: bool,
|
||||
config_layer_stack: Option<&ConfigLayerStack>,
|
||||
plugin_hook_sources: Vec<PluginHookSource>,
|
||||
plugin_hook_load_warnings: Vec<String>,
|
||||
shell: CommandShell,
|
||||
) -> Self {
|
||||
if !enabled {
|
||||
@@ -85,7 +91,11 @@ impl ClaudeHooksEngine {
|
||||
}
|
||||
|
||||
let _ = schema_loader::generated_hook_schemas();
|
||||
let discovered = discovery::discover_handlers(config_layer_stack);
|
||||
let discovered = discovery::discover_handlers(
|
||||
config_layer_stack,
|
||||
plugin_hook_sources,
|
||||
plugin_hook_load_warnings,
|
||||
);
|
||||
Self {
|
||||
handlers: discovered.handlers,
|
||||
warnings: discovered.warnings,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -15,7 +16,10 @@ use codex_config::ManagedHooksRequirementsToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_config::TomlValue;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -105,6 +109,8 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
@@ -188,6 +194,8 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
@@ -295,6 +303,8 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
@@ -325,3 +335,192 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||
assert_eq!(preview[0].source_path, hooks_json_path);
|
||||
assert_eq!(preview[1].source_path, config_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let plugin_root =
|
||||
AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root");
|
||||
let plugin_data_root =
|
||||
AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root");
|
||||
fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir");
|
||||
let source_path = plugin_root.join("hooks/hooks.json");
|
||||
let log_path = plugin_root.join("env.json");
|
||||
let script_path = plugin_root.join("hooks/write_env.py");
|
||||
fs::write(
|
||||
script_path.as_path(),
|
||||
format!(
|
||||
r#"import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
Path(r"{log_path}").write_text(json.dumps({{
|
||||
"plugin": os.environ.get("PLUGIN_ROOT"),
|
||||
"claude": os.environ.get("CLAUDE_PLUGIN_ROOT"),
|
||||
}}), encoding="utf-8")
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
),
|
||||
)
|
||||
.expect("write hook script");
|
||||
let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id");
|
||||
let plugin_hook_sources = vec![PluginHookSource {
|
||||
plugin_id,
|
||||
plugin_root: plugin_root.clone(),
|
||||
plugin_data_root: plugin_data_root.clone(),
|
||||
source_path: source_path.clone(),
|
||||
source_relative_path: "hooks/hooks.json".to_string(),
|
||||
hooks: HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("Bash".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
command: format!("python3 {}", script_path.display()),
|
||||
timeout_sec: Some(5),
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
/*config_layer_stack*/ None,
|
||||
plugin_hook_sources,
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: cwd(),
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
matcher_aliases: Vec::new(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
tool_input: serde_json::json!({ "command": "echo hello" }),
|
||||
});
|
||||
assert_eq!(preview.len(), 1);
|
||||
assert_eq!(preview[0].source, HookSource::Plugin);
|
||||
assert_eq!(preview[0].source_path, source_path);
|
||||
|
||||
let outcome = engine
|
||||
.run_pre_tool_use(PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: cwd(),
|
||||
transcript_path: None,
|
||||
model: "gpt-test".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
matcher_aliases: Vec::new(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
tool_input: serde_json::json!({ "command": "echo hello" }),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.hook_events.len(), 1);
|
||||
assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin);
|
||||
let logged: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(log_path.as_path()).expect("read env log"))
|
||||
.expect("parse env log");
|
||||
assert_eq!(
|
||||
logged,
|
||||
serde_json::json!({
|
||||
"plugin": plugin_root.display().to_string(),
|
||||
"claude": plugin_root.display().to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_hook_sources_expand_plugin_placeholders() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let plugin_root =
|
||||
AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root");
|
||||
let plugin_data_root =
|
||||
AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root");
|
||||
let source_path = plugin_root.join("hooks/hooks.json");
|
||||
let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id");
|
||||
let plugin_hook_sources = vec![PluginHookSource {
|
||||
plugin_id,
|
||||
plugin_root: plugin_root.clone(),
|
||||
plugin_data_root: plugin_data_root.clone(),
|
||||
source_path,
|
||||
source_relative_path: "hooks/hooks.json".to_string(),
|
||||
hooks: HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("Bash".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
command: "run ${PLUGIN_ROOT} ${CLAUDE_PLUGIN_ROOT} ${PLUGIN_DATA} ${CLAUDE_PLUGIN_DATA}"
|
||||
.to_string(),
|
||||
timeout_sec: Some(5),
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
/*config_layer_stack*/ None,
|
||||
plugin_hook_sources,
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
engine.handlers[0].command,
|
||||
format!(
|
||||
"run {} {} {} {}",
|
||||
plugin_root.display(),
|
||||
plugin_root.display(),
|
||||
plugin_data_root.display(),
|
||||
plugin_data_root.display()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.handlers[0].env,
|
||||
HashMap::from([
|
||||
("PLUGIN_ROOT".to_string(), plugin_root.display().to_string()),
|
||||
(
|
||||
"CLAUDE_PLUGIN_ROOT".to_string(),
|
||||
plugin_root.display().to_string()
|
||||
),
|
||||
(
|
||||
"PLUGIN_DATA".to_string(),
|
||||
plugin_data_root.display().to_string()
|
||||
),
|
||||
(
|
||||
"CLAUDE_PLUGIN_DATA".to_string(),
|
||||
plugin_data_root.display().to_string()
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_hook_load_warnings_are_startup_warnings() {
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
/*config_layer_stack*/ None,
|
||||
Vec::new(),
|
||||
vec!["failed plugin hook".to_string()],
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);
|
||||
}
|
||||
|
||||
@@ -551,6 +551,7 @@ mod tests {
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -542,6 +542,7 @@ mod tests {
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,7 @@ mod tests {
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -531,6 +531,7 @@ mod tests {
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +422,7 @@ mod tests {
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
@@ -25,6 +26,8 @@ pub struct HooksConfig {
|
||||
pub legacy_notify_argv: Option<Vec<String>>,
|
||||
pub feature_enabled: bool,
|
||||
pub config_layer_stack: Option<ConfigLayerStack>,
|
||||
pub plugin_hook_sources: Vec<PluginHookSource>,
|
||||
pub plugin_hook_load_warnings: Vec<String>,
|
||||
pub shell_program: Option<String>,
|
||||
pub shell_args: Vec<String>,
|
||||
}
|
||||
@@ -53,6 +56,8 @@ impl Hooks {
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
config.feature_enabled,
|
||||
config.config_layer_stack.as_ref(),
|
||||
config.plugin_hook_sources,
|
||||
config.plugin_hook_load_warnings,
|
||||
CommandShell {
|
||||
program: config.shell_program.unwrap_or_default(),
|
||||
args: config.shell_args,
|
||||
|
||||
@@ -13,6 +13,7 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-config = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -6,6 +6,8 @@ pub use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
mod load_outcome;
|
||||
mod plugin_id;
|
||||
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
pub use load_outcome::EffectiveSkillRoots;
|
||||
pub use load_outcome::LoadedPlugin;
|
||||
pub use load_outcome::PluginLoadOutcome;
|
||||
@@ -27,6 +29,16 @@ pub struct PluginCapabilitySummary {
|
||||
pub app_connector_ids: Vec<AppConnectorId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginHookSource {
|
||||
pub plugin_id: PluginId,
|
||||
pub plugin_root: AbsolutePathBuf,
|
||||
pub plugin_data_root: AbsolutePathBuf,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub source_relative_path: String,
|
||||
pub hooks: HookEventsToml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
pub plugin_id: PluginId,
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::AppConnectorId;
|
||||
use crate::PluginCapabilitySummary;
|
||||
use crate::PluginHookSource;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
@@ -21,6 +22,8 @@ pub struct LoadedPlugin<M> {
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, M>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub hook_sources: Vec<PluginHookSource>,
|
||||
pub hook_load_warnings: Vec<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -140,6 +143,22 @@ impl<M: Clone> PluginLoadOutcome<M> {
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn effective_plugin_hook_sources(&self) -> Vec<PluginHookSource> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.hook_sources.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn effective_plugin_hook_warnings(&self) -> Vec<String> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.hook_load_warnings.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
@@ -1563,6 +1563,7 @@ pub enum HookSource {
|
||||
Project,
|
||||
Mdm,
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
#[default]
|
||||
|
||||
Reference in New Issue
Block a user