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:
Abhinav
2026-04-28 14:17:18 -07:00
committed by GitHub
co-authored by Codex
parent 89698ad1c3
commit c6e7d564c3
36 changed files with 1129 additions and 194 deletions
+133 -152
View File
@@ -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;
+369
View File
@@ -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());
}
+52
View File
@@ -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 -1
View File
@@ -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()?
+12
View File
@@ -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();