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:
+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();
|
||||
|
||||
Reference in New Issue
Block a user