mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add hooks/list app-server RPC (#19778)
## Why We need a way to list the available hooks to expose via the TUI and App so users can view and manage their hooks ## What - Adds `hooks/list` for one or more `cwd` values that returns discovered hook metadata ## Stack 1. openai/codex#19705 2. This PR - openai/codex#19778 3. openai/codex#19840 4. openai/codex#19882 ## Review Notes The generated schema files account for most of the raw diff, these files have the core change: - `hooks/src/engine/discovery.rs` builds the inventory entries during hook discovery while leaving runtime handlers focused on execution. - `app-server/src/codex_message_processor.rs` wires `hooks/list` into the app-server flow for each requested `cwd`. - `app-server-protocol/src/protocol/v2.rs` defines the new v2 request/response payloads exposed on the wire. ### Core Changes `core/src/plugins/manager.rs` adds `plugins_for_layer_stack(...)` so `skills/list` and `hooks/list`can resolve plugin state for each requested `cwd` --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
6eab7519b4
commit
8774229a89
@@ -42,6 +42,7 @@ codex-external-agent-migration = { workspace = true }
|
||||
codex-external-agent-sessions = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
|
||||
@@ -196,6 +196,7 @@ Example with notification opt-out:
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `hooks/list` — list discovered hooks for one or more `cwd` values.
|
||||
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
|
||||
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
|
||||
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
|
||||
@@ -1452,6 +1453,43 @@ To enable or disable a skill by name:
|
||||
}
|
||||
```
|
||||
|
||||
Use `hooks/list` to fetch the discovered hooks for one or more `cwds`.
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "hooks/list",
|
||||
"id": 28,
|
||||
"params": {
|
||||
"cwds": ["/Users/me/project"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 28,
|
||||
"result": {
|
||||
"data": [{
|
||||
"cwd": "/Users/me/project",
|
||||
"hooks": [{
|
||||
"eventName": "pre_tool_use",
|
||||
"handlerType": "command",
|
||||
"matcher": "Bash",
|
||||
"command": "python3 /Users/me/hook.py",
|
||||
"timeoutSec": 5,
|
||||
"statusMessage": "running hook",
|
||||
"sourcePath": "/Users/me/.codex/config.toml",
|
||||
"source": "user",
|
||||
"pluginId": null,
|
||||
"displayOrder": 0
|
||||
}],
|
||||
"warnings": [],
|
||||
"errors": []
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Apps
|
||||
|
||||
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config.
|
||||
|
||||
@@ -76,6 +76,9 @@ use codex_app_server_protocol::GetConversationSummaryParams;
|
||||
use codex_app_server_protocol::GetConversationSummaryResponse;
|
||||
use codex_app_server_protocol::GitDiffToRemoteResponse;
|
||||
use codex_app_server_protocol::GitInfo as ApiGitInfo;
|
||||
use codex_app_server_protocol::HookMetadata;
|
||||
use codex_app_server_protocol::HooksListParams;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
@@ -233,6 +236,7 @@ use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::workspace_settings;
|
||||
use codex_config::CloudRequirementsLoadError;
|
||||
use codex_config::CloudRequirementsLoadErrorCode;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::loader::project_trust_key;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core::CodexThread;
|
||||
@@ -719,6 +723,23 @@ impl CodexMessageProcessor {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a caller-provided cwd into the absolute cwd and matching config layers
|
||||
/// so list-style RPCs share the same per-cwd error handling.
|
||||
async fn resolve_cwd_config(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> {
|
||||
let cwd_abs =
|
||||
AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?;
|
||||
let config_layer_stack = self
|
||||
.config_manager
|
||||
.load_config_layers_for_cwd(cwd_abs.clone())
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok((cwd_abs, config_layer_stack))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_config_mutation(&self) {
|
||||
self.clear_plugin_related_caches();
|
||||
}
|
||||
@@ -1086,6 +1107,10 @@ impl CodexMessageProcessor {
|
||||
self.skills_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::HooksList { request_id, params } => {
|
||||
self.hooks_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::MarketplaceAdd { request_id, params } => {
|
||||
self.marketplace_add(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -6248,43 +6273,24 @@ impl CodexMessageProcessor {
|
||||
.map(|environment| environment.get_filesystem());
|
||||
let mut data = Vec::new();
|
||||
for cwd in cwds {
|
||||
let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(message) => {
|
||||
let error_path = cwd.clone();
|
||||
data.push(codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: vec![codex_app_server_protocol::SkillErrorInfo {
|
||||
path: error_path,
|
||||
message,
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let extra_roots = extra_roots_by_cwd
|
||||
.get(&cwd)
|
||||
.map_or(&[][..], std::vec::Vec::as_slice);
|
||||
let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let error_path = cwd.clone();
|
||||
data.push(codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: vec![codex_app_server_protocol::SkillErrorInfo {
|
||||
path: error_path,
|
||||
message: err.to_string(),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config_layer_stack = match self
|
||||
.config_manager
|
||||
.load_config_layers_for_cwd(cwd_abs.clone())
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
let error_path = cwd.clone();
|
||||
data.push(codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: vec![codex_app_server_protocol::SkillErrorInfo {
|
||||
path: error_path,
|
||||
message: err.to_string(),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let effective_skill_roots = if workspace_codex_plugins_enabled {
|
||||
plugins_manager
|
||||
.effective_skill_roots_for_layer_stack(&config_layer_stack, &config)
|
||||
@@ -6316,6 +6322,86 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
Ok(SkillsListResponse { data })
|
||||
}
|
||||
|
||||
async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) {
|
||||
let result = self.hooks_list_response(params).await;
|
||||
self.outgoing.send_result(request_id, result).await;
|
||||
}
|
||||
|
||||
/// Handle `hooks/list` by resolving hooks for each requested cwd.
|
||||
async fn hooks_list_response(
|
||||
&self,
|
||||
params: HooksListParams,
|
||||
) -> Result<HooksListResponse, JSONRPCErrorError> {
|
||||
let HooksListParams { cwds } = params;
|
||||
let cwds = if cwds.is_empty() {
|
||||
vec![self.config.cwd.to_path_buf()]
|
||||
} else {
|
||||
cwds
|
||||
};
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let mut data = Vec::new();
|
||||
for cwd in cwds {
|
||||
let config = match self
|
||||
.config_manager
|
||||
.load_for_cwd(
|
||||
/*request_overrides*/ None,
|
||||
ConfigOverrides::default(),
|
||||
Some(cwd.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
let error_path = cwd.clone();
|
||||
data.push(codex_app_server_protocol::HooksListEntry {
|
||||
cwd,
|
||||
hooks: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
errors: vec![codex_app_server_protocol::HookErrorInfo {
|
||||
path: error_path,
|
||||
message: err.to_string(),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let workspace_codex_plugins_enabled = self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await;
|
||||
let plugins_enabled =
|
||||
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
|
||||
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
|
||||
{
|
||||
plugins_manager
|
||||
.plugins_for_layer_stack(
|
||||
&config.config_layer_stack,
|
||||
&config,
|
||||
/*plugin_hooks_feature_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
codex_core::plugins::PluginLoadOutcome::default()
|
||||
};
|
||||
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
||||
feature_enabled: config.features.enabled(Feature::CodexHooks),
|
||||
config_layer_stack: Some(config.config_layer_stack),
|
||||
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
|
||||
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
|
||||
..Default::default()
|
||||
});
|
||||
data.push(codex_app_server_protocol::HooksListEntry {
|
||||
cwd,
|
||||
hooks: hooks_to_info(&hooks.hooks),
|
||||
warnings: hooks.warnings,
|
||||
errors: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(HooksListResponse { data })
|
||||
}
|
||||
|
||||
async fn marketplace_remove(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
@@ -8623,6 +8709,24 @@ fn skills_to_info(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
|
||||
hooks
|
||||
.iter()
|
||||
.map(|hook| HookMetadata {
|
||||
event_name: hook.event_name.into(),
|
||||
handler_type: hook.handler_type.into(),
|
||||
matcher: hook.matcher.clone(),
|
||||
command: hook.command.clone(),
|
||||
timeout_sec: hook.timeout_sec,
|
||||
status_message: hook.status_message.clone(),
|
||||
source_path: hook.source_path.clone(),
|
||||
source: hook.source.into(),
|
||||
plugin_id: hook.plugin_id.clone(),
|
||||
display_order: hook.display_order,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plugin_skills_to_info(
|
||||
skills: &[codex_core::skills::SkillMetadata],
|
||||
disabled_skill_paths: &std::collections::HashSet<AbsolutePathBuf>,
|
||||
|
||||
@@ -37,6 +37,7 @@ use codex_app_server_protocol::FsWriteFileParams;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetConversationSummaryParams;
|
||||
use codex_app_server_protocol::HooksListParams;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
@@ -580,6 +581,15 @@ impl McpProcess {
|
||||
self.send_request("skills/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `hooks/list` JSON-RPC request.
|
||||
pub async fn send_hooks_list_request(
|
||||
&mut self,
|
||||
params: HooksListParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("hooks/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `marketplace/add` JSON-RPC request.
|
||||
pub async fn send_marketplace_add_request(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::HookEventName;
|
||||
use codex_app_server_protocol::HookHandlerType;
|
||||
use codex_app_server_protocol::HookMetadata;
|
||||
use codex_app_server_protocol::HookSource;
|
||||
use codex_app_server_protocol::HooksListEntry;
|
||||
use codex_app_server_protocol::HooksListParams;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
r#"[hooks]
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "python3 /tmp/listed-hook.py"
|
||||
timeout = 5
|
||||
statusMessage = "running listed hook"
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> Result<()> {
|
||||
let plugin_root = codex_home.join("plugins/cache/test/demo/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::create_dir_all(plugin_root.join("hooks"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"demo"}"#,
|
||||
)?;
|
||||
std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?;
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
plugin_hooks = true
|
||||
codex_hooks = true
|
||||
|
||||
[plugins."demo@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_list_shows_discovered_hook() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
write_user_hook_config(codex_home.path())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_hooks_list_request(HooksListParams {
|
||||
cwds: vec![cwd.path().to_path_buf()],
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let HooksListResponse { data } = to_response(response)?;
|
||||
assert_eq!(
|
||||
data,
|
||||
vec![HooksListEntry {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
hooks: vec![HookMetadata {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
handler_type: HookHandlerType::Command,
|
||||
matcher: Some("Bash".to_string()),
|
||||
command: Some("python3 /tmp/listed-hook.py".to_string()),
|
||||
timeout_sec: 5,
|
||||
status_message: Some("running listed hook".to_string()),
|
||||
source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
|
||||
codex_home.path().join("config.toml")
|
||||
)?,)?,
|
||||
source: HookSource::User,
|
||||
plugin_id: None,
|
||||
display_order: 0,
|
||||
}],
|
||||
warnings: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
}]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
write_plugin_hook_config(
|
||||
codex_home.path(),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo plugin hook",
|
||||
"timeout": 7,
|
||||
"statusMessage": "running plugin hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_hooks_list_request(HooksListParams {
|
||||
cwds: vec![cwd.path().to_path_buf()],
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let HooksListResponse { data } = to_response(response)?;
|
||||
assert_eq!(
|
||||
data,
|
||||
vec![HooksListEntry {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
hooks: vec![HookMetadata {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
handler_type: HookHandlerType::Command,
|
||||
matcher: Some("Bash".to_string()),
|
||||
command: Some("echo plugin hook".to_string()),
|
||||
timeout_sec: 7,
|
||||
status_message: Some("running plugin hook".to_string()),
|
||||
source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
|
||||
codex_home
|
||||
.path()
|
||||
.join("plugins/cache/test/demo/local/hooks/hooks.json"),
|
||||
)?,)?,
|
||||
source: HookSource::Plugin,
|
||||
plugin_id: Some("demo@test".to_string()),
|
||||
display_order: 0,
|
||||
}],
|
||||
warnings: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
}]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
write_plugin_hook_config(codex_home.path(), "{ not-json")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_hooks_list_request(HooksListParams {
|
||||
cwds: vec![cwd.path().to_path_buf()],
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let HooksListResponse { data } = to_response(response)?;
|
||||
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].hooks, Vec::new());
|
||||
assert_eq!(data[0].warnings.len(), 1);
|
||||
assert!(
|
||||
data[0].warnings[0].contains("failed to parse plugin hooks config"),
|
||||
"unexpected warnings: {:?}",
|
||||
data[0].warnings
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
codex_hooks = false
|
||||
"#,
|
||||
)?;
|
||||
std::fs::create_dir_all(workspace.path().join(".git"))?;
|
||||
std::fs::create_dir_all(workspace.path().join(".codex"))?;
|
||||
std::fs::write(
|
||||
workspace.path().join(".codex/config.toml"),
|
||||
r#"[features]
|
||||
codex_hooks = true
|
||||
|
||||
[hooks]
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "echo project hook"
|
||||
timeout = 5
|
||||
"#,
|
||||
)?;
|
||||
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_hooks_list_request(HooksListParams {
|
||||
cwds: vec![
|
||||
codex_home.path().to_path_buf(),
|
||||
workspace.path().to_path_buf(),
|
||||
],
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let HooksListResponse { data } = to_response(response)?;
|
||||
assert_eq!(
|
||||
data,
|
||||
vec![
|
||||
HooksListEntry {
|
||||
cwd: codex_home.path().to_path_buf(),
|
||||
hooks: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
},
|
||||
HooksListEntry {
|
||||
cwd: workspace.path().to_path_buf(),
|
||||
hooks: vec![HookMetadata {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
handler_type: HookHandlerType::Command,
|
||||
matcher: Some("Bash".to_string()),
|
||||
command: Some("echo project hook".to_string()),
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: AbsolutePathBuf::try_from(
|
||||
workspace.path().join(".codex/config.toml"),
|
||||
)?,
|
||||
source: HookSource::Project,
|
||||
plugin_id: None,
|
||||
display_order: 0,
|
||||
}],
|
||||
warnings: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod experimental_api;
|
||||
mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
mod fs;
|
||||
mod hooks_list;
|
||||
mod initialize;
|
||||
mod marketplace_add;
|
||||
mod marketplace_remove;
|
||||
|
||||
Reference in New Issue
Block a user