diff --git a/TODO-DEVELOPER-A.md b/TODO-DEVELOPER-A.md new file mode 100644 index 00000000000..cccbbcc12b9 --- /dev/null +++ b/TODO-DEVELOPER-A.md @@ -0,0 +1,239 @@ +# 🔵 Developer A: Core Execution Engine (Backend Focus) + +## Your Mission + +Implement the core hook execution engine, integrate with Codex's event system, and build comprehensive testing infrastructure. + +## 🎯 Your Responsibilities + +- Hook execution coordination and management +- Event system integration with existing Codex architecture +- Core testing infrastructure and validation +- Performance optimization and error handling + +## 📁 Your Primary Files + +- `codex-rs/core/src/hooks/manager.rs` ⭐ **Your main file** +- `codex-rs/core/src/hooks/executor.rs` ⭐ **Your main file** +- `codex-rs/core/src/hooks/executors/` (new directory) ⭐ **Create this** +- `codex-rs/core/src/protocol.rs` +- `codex-rs/core/src/codex.rs` +- `codex-rs/core/src/agent.rs` +- `codex-rs/exec/src/event_processor.rs` + +## 🚀 Start Here: Phase 2.1 - Core Hook Manager + +### 🔴 HIGH PRIORITY: Complete Hook Manager Implementation + +**File**: `codex-rs/core/src/hooks/manager.rs` + +#### Current Status + +The file exists with basic structure but needs complete implementation. + +#### Your Tasks + +- [ ] **Hook Execution Coordination** + + - Implement the `trigger_event` method to actually execute hooks + - Add hook filtering based on event type and conditions + - Coordinate execution of multiple hooks for the same event + +- [ ] **Event Subscription and Routing** + + - Create event subscription mechanism + - Route events to appropriate hooks based on registry + - Handle event filtering and matching + +- [ ] **Error Handling and Logging** + + - Implement comprehensive error handling for hook failures + - Add structured logging for hook execution + - Handle partial failures gracefully + +- [ ] **Performance Monitoring** + - Add execution time tracking + - Implement hook execution metrics + - Monitor resource usage + +#### Implementation Guide + +```rust +// In manager.rs - implement this method +impl HookManager { + pub async fn trigger_event(&self, event: LifecycleEvent) -> Result<(), HookError> { + if !self.config.hooks.enabled { + return Ok(()); + } + + // 1. Get matching hooks from registry + let context = HookContext::new(event.clone(), /* working_dir */); + let hooks = self.registry.get_matching_hooks(&event, &context)?; + + // 2. Execute hooks based on priority and mode + // 3. Handle errors and collect results + // 4. Log execution metrics + + // TODO: Your implementation here + } +} +``` + +--- + +## 📋 Your Complete Task List + +### 🔄 Phase 2: Hook Execution Engine + +#### 2.1 Core Hook Manager ⭐ **COMPLETED** ✅ + +- [x] Complete hook execution coordination in `manager.rs` +- [x] Implement event subscription and routing +- [x] Add error handling and logging +- [x] Performance monitoring and metrics + +#### 2.2 Hook Executor Framework ✅ **COMPLETED** + +- [x] Complete timeout management and cancellation in `executor.rs` +- [x] Implement error isolation and recovery +- [x] Add execution mode support (blocking/non-blocking, parallel/sequential) +- [x] Hook execution result aggregation + +#### 2.3 Hook Executor Implementations ✅ **COMPLETED** + +- [x] Create `codex-rs/core/src/hooks/executors/mod.rs` +- [x] Implement `ScriptExecutor` in `executors/script.rs` +- [x] Implement `WebhookExecutor` in `executors/webhook.rs` +- [x] Implement `McpToolExecutor` in `executors/mcp.rs` + +### 🔄 Phase 3: Event System Integration + +#### 3.1 Protocol Extensions ✅ **COMPLETED** + +- [x] Add lifecycle event types to `protocol.rs` +- [x] Add hook execution events for monitoring +- [x] Update event serialization/deserialization + +#### 3.2 Core Integration Points ✅ **COMPLETED** + +- [x] Integrate hook manager in `codex.rs` +- [x] Add hook trigger points in `agent.rs` +- [x] Session and task lifecycle hooks + +#### 3.3 Execution Integration ✅ **COMPLETED** + +- [x] Add hook execution to `event_processor.rs` +- [x] Command execution hooks (before/after) +- [x] Patch application hooks (before/after) +- [x] MCP tool execution hooks + +### 🔄 Phase 6: Testing and Validation + +#### 6.1 Unit Tests + +- [ ] Test hook execution coordination +- [ ] Test timeout and error handling +- [ ] Test individual hook executors + +#### 6.2 Integration Tests + +- [ ] Test hook execution with real events +- [ ] Test hook error handling and recovery +- [ ] Test performance impact + +#### 6.3 End-to-End Tests + +- [ ] Test complete hook workflows +- [ ] Test integration with existing Codex functionality + +--- + +## 🎯 Success Criteria + +By the end of your work, you should achieve: + +- [ ] **Hooks execute successfully** with proper error handling +- [ ] **Performance impact < 5%** on normal Codex operations +- [ ] **All hook types working** (script, webhook, MCP) +- [ ] **Integration tests passing** with good coverage + +--- + +## 🤝 Coordination with Developer B + +### What Developer B is Working On + +- Client-side integration (CLI, TypeScript) +- Documentation and examples +- Advanced features and monitoring + +### Shared Dependencies (Already Complete ✅) + +- Hook Types (`types.rs`) +- Hook Context (`context.rs`) +- Hook Configuration (`config.rs`) +- Hook Registry (`registry.rs`) + +### Communication + +- **Daily sync**: Share progress and blockers +- **Branch naming**: Use `feat/hook-execution-*` pattern +- **File ownership**: You own backend Rust files +- **Testing**: Run full test suite before merging + +--- + +## 🚀 Getting Started Commands + +```bash +# Create your feature branch +git checkout -b feat/hook-execution-engine + +# Start with the manager implementation +code codex-rs/core/src/hooks/manager.rs + +# Test your changes +cd codex-rs && cargo test hooks + +# Commit your progress +git add . +git commit -m "feat: implement hook execution coordination" +git push origin feat/hook-execution-engine +``` + +--- + +## 📊 Your Progress Tracking + +### Phase 2: Hook Execution Engine + +- [x] **2.1 Complete**: Core Hook Manager (4/4 tasks) ✅ +- [x] **2.2 Complete**: Hook Executor Framework (4/4 tasks) ✅ +- [x] **2.3 Complete**: Hook Executor Implementations (4/4 tasks) ✅ + +### Phase 3: Event System Integration + +- [x] **3.1 Complete**: Protocol Extensions (3/3 tasks) ✅ +- [x] **3.2 Complete**: Core Integration Points (3/3 tasks) ✅ +- [x] **3.3 Complete**: Execution Integration (4/4 tasks) ✅ + +### Phase 6: Testing and Validation + +- [ ] **6.1 Complete**: Unit Tests (0/3 tasks) +- [ ] **6.2 Complete**: Integration Tests (0/3 tasks) +- [ ] **6.3 Complete**: End-to-End Tests (0/2 tasks) + +**Your Total Progress: 22/30 tasks complete (73%)** + +--- + +## 💡 Tips for Success + +1. **Start Small**: Begin with basic hook execution in `manager.rs` +2. **Test Early**: Write tests as you implement features +3. **Use Existing Patterns**: Follow Codex's existing async patterns +4. **Performance First**: Keep the async, non-blocking design +5. **Error Handling**: Hooks should never crash the main process +6. **Logging**: Add comprehensive tracing for debugging + +**You've got this! 🚀** diff --git a/TODO-DEVELOPER-B.md b/TODO-DEVELOPER-B.md new file mode 100644 index 00000000000..3dbfe40cf53 --- /dev/null +++ b/TODO-DEVELOPER-B.md @@ -0,0 +1,511 @@ +# 🟢 Developer B: Client Integration & Documentation (Frontend/Docs Focus) + +## Your Mission + +Build client-side hook support, create comprehensive documentation, and implement advanced user-facing features. + +## 🎯 Your Responsibilities + +- Client-side hook support and CLI integration +- Documentation, examples, and user-facing features +- Advanced features and monitoring capabilities +- Configuration templates and user experience +- Magentic-One QA integration and automated testing + +## 📁 Your Primary Files + +- `codex-cli/src/utils/agent/agent-loop.ts` ⭐ **Your main file** +- `docs/` (new directory) ⭐ **Create this** +- `examples/` (new directory) ⭐ **Create this** +- CLI configuration files +- Documentation and example scripts + +## 🚀 Start Here: Phase 4.1 - TypeScript/CLI Integration + +### 🔴 HIGH PRIORITY: Add Client-Side Hook Support + +**File**: `codex-cli/src/utils/agent/agent-loop.ts` + +#### Current Status + +The file exists but needs hook integration for client-side event handling. + +#### Your Tasks + +- [ ] **Hook Event Handling in Agent Loop** + + - Add hook event emission from the agent loop + - Integrate with existing event processing + - Handle hook execution status reporting + +- [ ] **Client-Side Hook Configuration** + + - Add hook configuration loading in CLI + - Support for hook enable/disable flags + - Configuration validation and error reporting + +- [ ] **Hook Execution Status Reporting** + - Display hook execution status in CLI output + - Show hook results and errors to users + - Add debugging information for hook troubleshooting + +#### Implementation Guide + +```typescript +// In agent-loop.ts - add hook event emission +export async function runAgentLoop(options: AgentLoopOptions) { + // Existing code... + + // Add hook event emission + if (config.hooks?.enabled) { + await emitLifecycleEvent({ + type: "session_start", + session_id: sessionId, + model: options.model, + timestamp: new Date().toISOString(), + }); + } + + // TODO: Your implementation here +} +``` + +--- + +## 📋 Your Complete Task List + +### 🔄 Phase 4: Client-Side Integration + +#### 4.1 TypeScript/CLI Integration ⭐ **START HERE** + +- [ ] Add client-side hook support to `agent-loop.ts` +- [ ] Hook event handling in agent loop +- [ ] Client-side hook configuration +- [ ] Hook execution status reporting + +#### 4.2 Event Processing Updates + +- [ ] Update CLI configuration to support hooks +- [ ] Command line flags for hook control +- [ ] Hook configuration file discovery +- [ ] Hook status and debugging output + +### 🔄 Phase 5: Configuration and Documentation + +#### 5.1 Configuration System + +- [ ] Create default `hooks.toml` configuration template +- [ ] Add configuration validation and error reporting +- [ ] Support for profile-specific hook configurations +- [ ] Environment-based hook configuration overrides + +#### 5.2 Example Hooks and Scripts ⭐ **High Impact** + +- [ ] Create `examples/hooks/` directory with example hook scripts +- [ ] Session logging hook example +- [ ] Security scanning hook example +- [ ] Slack notification webhook example +- [ ] File backup hook example +- [ ] Analytics/metrics collection hook example +- [ ] Create hook script templates for common use cases + +#### 5.3 Documentation ⭐ **High Impact** + +- [ ] Create `docs/hooks.md` - Comprehensive hooks documentation +- [ ] Hook system overview and architecture +- [ ] Configuration reference +- [ ] Hook types and executors +- [ ] Security considerations +- [ ] Troubleshooting guide +- [ ] Update main README.md with hooks section +- [ ] Create hook development guide +- [ ] Add API documentation for hook development + +### 🔄 Phase 7: Advanced Features + +#### 7.1 Advanced Hook Features + +- [ ] Hook dependency management and ordering +- [ ] Hook result chaining and data passing +- [ ] Hook execution metrics and monitoring +- [ ] Hook execution history and logging + +#### 7.2 Additional Hook Types + +- [ ] Database hook executor (for logging to databases) +- [ ] Message queue hook executor (for async processing) +- [ ] File system hook executor (for file operations) +- [ ] Custom plugin hook executor (for extensibility) + +#### 7.3 Management and Monitoring + +- [ ] Hook execution dashboard/monitoring +- [ ] Hook performance metrics collection +- [ ] Hook error reporting and alerting +- [ ] Hook configuration management tools + +### 🤖 Phase 8: Magentic-One QA Integration + +#### 8.1 Magentic-One Setup and Configuration + +- [ ] Install and configure Magentic-One multi-agent system +- [ ] Set up secure containerized environment for agent execution +- [ ] Configure GPT-4o model client for Orchestrator agent +- [ ] Implement safety protocols and monitoring +- [ ] Create agent team configuration for QA workflows + +#### 8.2 Automated QA Agent Implementation + +- [ ] Create QA Orchestrator agent for lifecycle hooks testing +- [ ] Implement FileSurfer agent for configuration file validation +- [ ] Configure WebSurfer agent for webhook endpoint testing +- [ ] Set up Coder agent for test script generation +- [ ] Implement ComputerTerminal agent for CLI testing automation + +#### 8.3 QA Workflow Integration + +- [ ] Create automated test suite generation workflows +- [ ] Implement hook configuration validation automation +- [ ] Set up end-to-end testing scenarios with Magentic-One +- [ ] Create performance benchmarking automation +- [ ] Implement regression testing workflows + +#### 8.4 Safety and Monitoring + +- [ ] Implement container isolation for agent execution +- [ ] Set up comprehensive logging and monitoring +- [ ] Create human oversight protocols +- [ ] Implement access restrictions and safeguards +- [ ] Set up prompt injection protection + +--- + +## 🎯 Success Criteria + +By the end of your work, you should achieve: + +- [ ] **CLI users can easily configure and use hooks** with clear documentation +- [ ] **Comprehensive documentation with examples** that users love +- [ ] **Hook configuration validation** with helpful error messages +- [ ] **Advanced features** that enhance user experience +- [ ] **Magentic-One QA system** providing automated testing and validation + +--- + +## 📝 Documentation Structure to Create + +### `docs/hooks.md` - Main Documentation + +```markdown +# Codex Lifecycle Hooks + +## Overview + +Brief introduction to the hooks system + +## Quick Start + +Simple example to get users started + +## Configuration Reference + +Complete TOML configuration options + +## Hook Types + +- Script Hooks +- Webhook Hooks +- MCP Tool Hooks +- Custom Executables + +## Examples + +Real-world use cases with code + +## Troubleshooting + +Common issues and solutions + +## API Reference + +For advanced users and developers +``` + +### `examples/hooks/` - Example Scripts + +``` +examples/hooks/ +├── session-logging/ +│ ├── log-session-start.sh +│ ├── log-session-end.sh +│ └── README.md +├── notifications/ +│ ├── slack-webhook.sh +│ ├── email-notification.py +│ └── README.md +├── security/ +│ ├── scan-commands.py +│ ├── backup-files.sh +│ └── README.md +└── analytics/ + ├── track-usage.js + ├── performance-metrics.py + └── README.md +``` + +--- + +## 🤝 Coordination with Developer A + +### What Developer A is Working On + +- Core hook execution engine (Rust backend) +- Event system integration +- Testing infrastructure + +### Shared Dependencies (Already Complete ✅) + +- Hook Types (`types.rs`) +- Hook Context (`context.rs`) +- Hook Configuration (`config.rs`) +- Hook Registry (`registry.rs`) + +### Communication + +- **Daily sync**: Share progress and blockers +- **Branch naming**: Use `feat/hook-client-*` pattern +- **File ownership**: You own frontend/docs files +- **Testing**: Test CLI integration thoroughly + +--- + +## 🚀 Getting Started Commands + +```bash +# Create your feature branch +git checkout -b feat/hook-client-integration + +# Start with CLI integration +code codex-cli/src/utils/agent/agent-loop.ts + +# Create documentation structure +mkdir -p docs examples/hooks + +# Create your first example +mkdir examples/hooks/session-logging +echo '#!/bin/bash\necho "Session started: $CODEX_SESSION_ID"' > examples/hooks/session-logging/log-session-start.sh + +# Test your changes +cd codex-cli && npm test + +# Commit your progress +git add . +git commit -m "feat: add client-side hook support" +git push origin feat/hook-client-integration +``` + +--- + +## 📊 Your Progress Tracking + +### Phase 4: Client-Side Integration + +- [ ] **4.1 Complete**: TypeScript/CLI Integration (0/4 tasks) +- [ ] **4.2 Complete**: Event Processing Updates (0/4 tasks) + +### Phase 5: Configuration and Documentation + +- [ ] **5.1 Complete**: Configuration System (0/4 tasks) +- [ ] **5.2 Complete**: Example Hooks and Scripts (0/7 tasks) +- [ ] **5.3 Complete**: Documentation (0/9 tasks) + +### Phase 7: Advanced Features + +- [ ] **7.1 Complete**: Advanced Hook Features (0/4 tasks) +- [ ] **7.2 Complete**: Additional Hook Types (0/4 tasks) +- [ ] **7.3 Complete**: Management and Monitoring (0/4 tasks) + +### Phase 8: Magentic-One QA Integration + +- [ ] **8.1 Complete**: Magentic-One Setup and Configuration (0/5 tasks) +- [ ] **8.2 Complete**: Automated QA Agent Implementation (0/5 tasks) +- [ ] **8.3 Complete**: QA Workflow Integration (0/5 tasks) +- [ ] **8.4 Complete**: Safety and Monitoring (0/5 tasks) + +**Your Total Progress: 0/60 tasks complete** + +--- + +## 🤖 Magentic-One Implementation Guide + +### Installation and Setup + +```bash +# Install Magentic-One and dependencies +pip install "autogen-agentchat" "autogen-ext[magentic-one,openai]" +playwright install --with-deps chromium + +# Set up environment variables +export OPENAI_API_KEY="your-api-key" +export MAGENTIC_ONE_WORKSPACE="/path/to/safe/workspace" +``` + +### Basic QA Agent Configuration + +```python +# qa_agent.py - Basic Magentic-One QA setup +import asyncio +from autogen_ext.models.openai import OpenAIChatCompletionClient +from autogen_ext.teams.magentic_one import MagenticOne +from autogen_agentchat.ui import Console + +async def test_hooks_configuration(): + client = OpenAIChatCompletionClient(model="gpt-4o") + m1 = MagenticOne(client=client) + + task = """ + Test the Codex lifecycle hooks system: + 1. Validate hooks.toml configuration files + 2. Test script hook execution + 3. Test webhook hook endpoints + 4. Generate test reports + """ + + result = await Console(m1.run_stream(task=task)) + return result + +if __name__ == "__main__": + asyncio.run(test_hooks_configuration()) +``` + +### QA Workflow Examples + +#### 1. Configuration Validation + +```python +# Magentic-One task for validating hook configurations +task = """ +Analyze the hooks.toml configuration file: +1. Check syntax and structure +2. Validate hook types and parameters +3. Test condition expressions +4. Verify file paths and permissions +5. Generate validation report +""" +``` + +#### 2. End-to-End Testing + +```python +# Magentic-One task for E2E testing +task = """ +Perform end-to-end testing of lifecycle hooks: +1. Create test hook scripts +2. Configure test webhook endpoints +3. Run Codex with hooks enabled +4. Verify hook execution and results +5. Test error handling scenarios +""" +``` + +#### 3. Performance Benchmarking + +```python +# Magentic-One task for performance testing +task = """ +Benchmark lifecycle hooks performance: +1. Measure hook execution overhead +2. Test with multiple concurrent hooks +3. Analyze memory and CPU usage +4. Generate performance reports +5. Compare with baseline metrics +""" +``` + +### Safety Protocols + +#### Container Isolation + +```bash +# Run Magentic-One in Docker container +docker run -it --rm \ + -v $(pwd)/workspace:/workspace \ + -v $(pwd)/hooks-config:/config \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + magentic-one-qa:latest +``` + +#### Access Restrictions + +```python +# Restricted environment configuration +restricted_config = { + "allowed_domains": ["localhost", "127.0.0.1"], + "blocked_commands": ["rm", "sudo", "chmod"], + "max_execution_time": 300, # 5 minutes + "workspace_isolation": True +} +``` + +### Integration with Codex Testing + +```python +# codex_qa_integration.py +class CodexHooksQA: + def __init__(self): + self.magentic_one = MagenticOne(client=client) + + async def validate_hook_config(self, config_path): + task = f"Validate hooks configuration at {config_path}" + return await self.magentic_one.run_stream(task=task) + + async def test_hook_execution(self, hook_type, test_scenario): + task = f"Test {hook_type} hook with scenario: {test_scenario}" + return await self.magentic_one.run_stream(task=task) + + async def generate_test_report(self, results): + task = f"Generate comprehensive test report from: {results}" + return await self.magentic_one.run_stream(task=task) +``` + +--- + +## 💡 Tips for Success + +1. **User-First**: Think about the developer experience using hooks +2. **Examples Rule**: Great examples are worth 1000 words of docs +3. **Test Everything**: Test CLI integration with real hook configs +4. **Keep It Simple**: Start with basic examples, add complexity later +5. **Visual Aids**: Use diagrams and code examples liberally +6. **Error Messages**: Make configuration errors helpful and actionable + +## 🎨 Example Hook Script Template + +Create this as `examples/hooks/templates/basic-script.sh`: + +```bash +#!/bin/bash +# Basic Hook Script Template +# This script receives Codex lifecycle events via environment variables + +# Available environment variables: +# CODEX_EVENT_TYPE - The type of lifecycle event +# CODEX_TASK_ID - Current task ID (if applicable) +# CODEX_SESSION_ID - Current session ID +# CODEX_TIMESTAMP - Event timestamp + +echo "Hook executed!" +echo "Event: $CODEX_EVENT_TYPE" +echo "Task: $CODEX_TASK_ID" +echo "Session: $CODEX_SESSION_ID" +echo "Time: $CODEX_TIMESTAMP" + +# Add your custom logic here +# Examples: +# - Log to a file +# - Send notifications +# - Update external systems +# - Run security checks +``` + +**You've got this! 🚀** diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000000..85f174cd870 --- /dev/null +++ b/TODO.md @@ -0,0 +1,707 @@ +# Codex Lifecycle Hooks Implementation TODO + +## Overview + +Implementation of a comprehensive lifecycle hooks system for Codex that allows external scripts, webhooks, and integrations to be triggered at specific points in the Codex execution lifecycle. + +## Architecture Goals + +- Event-driven hook system integrated with existing Codex event architecture +- Support for multiple hook types: scripts, webhooks, MCP tools, custom executables +- Configurable, secure, and performant execution +- Non-blocking execution with proper error handling and timeouts + +--- + +## Phase 1: Core Infrastructure + +### 1.1 Hook Type Definitions and Core Types + +- [x] Create `codex-rs/core/src/hooks/mod.rs` - Main hooks module +- [x] Create `codex-rs/core/src/hooks/types.rs` - Core hook type definitions + - [x] Define `LifecycleEvent` enum with all lifecycle events + - [x] Define `HookConfig` struct for hook configuration + - [x] Define `HookExecutionContext` for runtime context + - [x] Define `HookResult` and error types +- [x] Create `codex-rs/core/src/hooks/context.rs` - Hook execution context + - [x] Implement context data serialization + - [x] Environment variable injection logic + - [x] Temporary file management for hook data + +### 1.2 Hook Registry System + +- [x] Create `codex-rs/core/src/hooks/registry.rs` - Hook registry implementation + - [x] Hook registration and lookup functionality + - [x] Event filtering and matching logic + - [x] Hook priority and dependency management + - [x] Conditional execution support (hook conditions) + +### 1.3 Hook Configuration System + +- [x] Create `codex-rs/core/src/hooks/config.rs` - Configuration parsing + - [x] Parse `hooks.toml` configuration file + - [x] Validate hook configurations at startup + - [x] Support for environment variable substitution + - [x] Configuration schema validation +- [x] Modify `codex-rs/core/src/config.rs` - Add hooks to main config + - [x] Add `hooks` field to `Config` struct + - [x] Add `hooks` field to `ConfigFile` struct + - [x] Update config loading to include hooks configuration + - [x] Add default hooks configuration + +--- + +## Phase 2: Hook Execution Engine + +### 2.1 Core Hook Manager + +- [x] Create `codex-rs/core/src/hooks/manager.rs` - Hook manager implementation + - [x] Hook registration and management + - [x] Event subscription and routing + - [ ] Hook execution coordination + - [ ] Error handling and logging + - [ ] Performance monitoring and metrics + +### 2.2 Hook Executor Framework + +- [x] Create `codex-rs/core/src/hooks/executor.rs` - Base hook execution engine + - [x] Async hook execution framework + - [ ] Timeout management and cancellation + - [ ] Error isolation and recovery + - [ ] Execution mode support (blocking/non-blocking, parallel/sequential) + - [ ] Hook execution result aggregation + +### 2.3 Hook Executor Implementations + +- [ ] Create `codex-rs/core/src/hooks/executors/mod.rs` - Executor module +- [ ] Create `codex-rs/core/src/hooks/executors/script.rs` - Script hook executor + - [ ] Shell script/command execution + - [ ] Environment variable injection + - [ ] Command line argument templating + - [ ] Working directory management + - [ ] Output capture and logging +- [ ] Create `codex-rs/core/src/hooks/executors/webhook.rs` - Webhook hook executor + - [ ] HTTP client implementation + - [ ] JSON payload serialization + - [ ] Authentication support (Bearer, API keys) + - [ ] Retry logic and error handling + - [ ] Request/response logging +- [ ] Create `codex-rs/core/src/hooks/executors/mcp.rs` - MCP tool hook executor + - [ ] Integration with existing MCP infrastructure + - [ ] MCP tool call execution + - [ ] Result processing and error handling + +--- + +## Phase 3: Event System Integration + +### 3.1 Protocol Extensions + +- [ ] Modify `codex-rs/core/src/protocol.rs` - Add lifecycle event types + - [ ] Add lifecycle event variants to existing event system + - [ ] Add hook execution events for monitoring + - [ ] Update event serialization/deserialization + - [ ] Add hook-specific event metadata + +### 3.2 Core Integration Points + +- [ ] Modify `codex-rs/core/src/codex.rs` - Integrate hook manager + - [ ] Initialize hook manager in Codex startup + - [ ] Hook manager lifecycle management + - [ ] Event routing to hook manager +- [ ] Modify `codex-rs/core/src/agent.rs` - Add hook trigger points + - [ ] Session lifecycle hooks (start/end) + - [ ] Task lifecycle hooks (start/complete) + - [ ] Agent message hooks + - [ ] Error handling hooks + +### 3.3 Execution Integration + +- [ ] Modify `codex-rs/exec/src/event_processor.rs` - Add hook execution + - [ ] Execution command hooks (before/after) + - [ ] Patch application hooks (before/after) + - [ ] MCP tool call hooks (before/after) +- [ ] Update existing execution paths to trigger hooks + - [ ] Command execution hooks + - [ ] Patch application hooks + - [ ] MCP tool execution hooks + +--- + +## Phase 4: Client-Side Integration + +### 4.1 TypeScript/CLI Integration + +- [ ] Modify `codex-cli/src/utils/agent/agent-loop.ts` - Add client-side hook support + - [ ] Hook event handling in agent loop + - [ ] Client-side hook configuration + - [ ] Hook execution status reporting +- [ ] Update CLI configuration to support hooks + - [ ] Command line flags for hook control + - [ ] Hook configuration file discovery + - [ ] Hook status and debugging output + +### 4.2 Event Processing Updates + +- [ ] Update event processors to handle hook events +- [ ] Add hook execution logging and status reporting +- [ ] Integrate hook results into CLI output + +--- + +## Phase 5: Configuration and Documentation + +### 5.1 Configuration System + +- [ ] Create default `hooks.toml` configuration template +- [ ] Add configuration validation and error reporting +- [ ] Support for profile-specific hook configurations +- [ ] Environment-based hook configuration overrides + +### 5.2 Example Hooks and Scripts + +- [ ] Create `examples/hooks/` directory with example hook scripts + - [ ] Session logging hook example + - [ ] Security scanning hook example + - [ ] Slack notification webhook example + - [ ] File backup hook example + - [ ] Analytics/metrics collection hook example +- [ ] Create hook script templates for common use cases + +### 5.3 Documentation + +- [ ] Create `docs/hooks.md` - Comprehensive hooks documentation + - [ ] Hook system overview and architecture + - [ ] Configuration reference + - [ ] Hook types and executors + - [ ] Security considerations + - [ ] Troubleshooting guide +- [ ] Update main README.md with hooks section +- [ ] Create hook development guide +- [ ] Add API documentation for hook development + +--- + +## Phase 6: Testing and Validation + +### 6.1 Unit Tests + +- [ ] Test hook type definitions and serialization +- [ ] Test hook registry functionality +- [ ] Test hook configuration parsing +- [ ] Test individual hook executors +- [ ] Test hook manager functionality + +### 6.2 Integration Tests + +- [ ] Test hook execution with real events +- [ ] Test hook error handling and recovery +- [ ] Test hook timeout and cancellation +- [ ] Test hook execution ordering and dependencies +- [ ] Test configuration loading and validation + +### 6.3 End-to-End Tests + +- [ ] **Complete Hook Workflows Testing** + + - [ ] Test session lifecycle hooks (start to end) + - [ ] Test task lifecycle hooks (creation to completion) + - [ ] Test execution hooks (command before/after) + - [ ] Test patch hooks (patch before/after) + - [ ] Test MCP tool hooks (tool before/after) + - [ ] Test error handling hooks + +- [ ] **Integration Testing with Existing Codex** + + - [ ] Test hooks with real Codex sessions + - [ ] Test hooks with existing MCP servers + - [ ] Test hooks with patch application workflows + - [ ] Test hooks with CLI agent loops + - [ ] Test hooks with configuration profiles + +- [ ] **Cross-Platform E2E Testing** + + - [ ] Test hooks on Linux environments + - [ ] Test hooks on macOS environments + - [ ] Test hooks on Windows environments + - [ ] Test hooks in Docker containers + +- [ ] **Real-World Scenario Testing** + + - [ ] Test with actual webhook endpoints + - [ ] Test with real script executions + - [ ] Test with production-like configurations + - [ ] Test with multiple concurrent sessions + - [ ] Test with large file operations + +- [ ] **Performance and Security E2E** + - [ ] Test performance impact of hooks + - [ ] Test security and sandboxing + - [ ] Test resource usage under load + - [ ] Test timeout and cancellation scenarios + +### 6.4 Performance and Security Testing + +- [ ] Performance benchmarks with hooks enabled/disabled +- [ ] Memory usage analysis +- [ ] Security testing for hook execution +- [ ] Sandbox isolation testing + +--- + +## Phase 7: Advanced Features + +### 7.1 Advanced Hook Features + +- [ ] Hook dependency management and ordering +- [ ] Conditional hook execution based on context +- [ ] Hook result chaining and data passing +- [ ] Hook execution metrics and monitoring +- [ ] Hook execution history and logging + +### 7.2 Additional Hook Types + +- [ ] Database hook executor (for logging to databases) +- [ ] Message queue hook executor (for async processing) +- [ ] File system hook executor (for file operations) +- [ ] Custom plugin hook executor (for extensibility) + +### 7.3 Management and Monitoring + +- [ ] Hook execution dashboard/monitoring +- [ ] Hook performance metrics collection +- [ ] Hook error reporting and alerting +- [ ] Hook configuration management tools + +--- + +## Phase 8: Magentic-One QA Integration + +### 8.1 Magentic-One Setup and Configuration + +- [ ] Install and configure Magentic-One multi-agent system +- [ ] Set up secure containerized environment for agent execution +- [ ] Configure GPT-4o model client for Orchestrator agent +- [ ] Implement safety protocols and monitoring +- [ ] Create agent team configuration for QA workflows + +### 8.2 Automated QA Agent Implementation + +- [ ] Create QA Orchestrator agent for lifecycle hooks testing +- [ ] Implement FileSurfer agent for configuration file validation +- [ ] Configure WebSurfer agent for webhook endpoint testing +- [ ] Set up Coder agent for test script generation +- [ ] Implement ComputerTerminal agent for CLI testing automation + +### 8.3 QA Workflow Integration + +- [ ] **Automated Test Suite Generation** + + - [ ] Generate E2E test scenarios automatically + - [ ] Create test data and configurations + - [ ] Generate test scripts for different hook types + - [ ] Create test environment setup automation + +- [ ] **Hook Configuration Validation Automation** + + - [ ] Automated TOML syntax validation + - [ ] Semantic validation of hook configurations + - [ ] Condition expression testing + - [ ] Cross-reference validation with available tools + +- [ ] **End-to-End Testing Scenarios with Magentic-One** + + - [ ] Automated full lifecycle testing workflows + - [ ] Multi-agent coordination for complex scenarios + - [ ] Real-world simulation testing + - [ ] Failure scenario testing and recovery + - [ ] Integration testing with external services + +- [ ] **Performance Benchmarking Automation** + + - [ ] Automated performance test execution + - [ ] Resource usage monitoring and reporting + - [ ] Comparative analysis with baselines + - [ ] Performance regression detection + +- [ ] **Regression Testing Workflows** + - [ ] Continuous integration testing + - [ ] Automated regression detection + - [ ] Historical comparison and trending + - [ ] Automated issue reporting and alerting + +### 8.4 Safety and Monitoring + +- [ ] Implement container isolation for agent execution +- [ ] Set up comprehensive logging and monitoring +- [ ] Create human oversight protocols +- [ ] Implement access restrictions and safeguards +- [ ] Set up prompt injection protection + +--- + +## Phase 9: Comprehensive E2E Testing + +### 9.1 Playwright E2E Test Suite + +- [ ] Set up Playwright testing framework for Codex CLI +- [ ] Create E2E test scenarios for hook workflows +- [ ] Test CLI interactions with hooks enabled +- [ ] Test configuration file loading and validation +- [ ] Test hook execution status reporting in CLI + +### 9.2 Real-World Integration Testing + +- [ ] Test with actual external webhook services +- [ ] Test with real MCP servers and tools +- [ ] Test with production-like Codex configurations +- [ ] Test with multiple concurrent Codex sessions +- [ ] Test with large-scale file operations and patches + +### 9.3 Cross-Environment E2E Validation + +- [ ] Test hooks in Docker containerized environments +- [ ] Test hooks across different operating systems +- [ ] Test hooks with different shell environments +- [ ] Test hooks with various permission configurations +- [ ] Test hooks with network restrictions and firewalls + +--- + +## Implementation Notes + +### Security Considerations + +- All hook execution must respect existing sandbox policies +- Hook scripts should be validated for permissions and safety +- Timeout management to prevent hanging hooks +- Error isolation to prevent hook failures from crashing Codex +- Secure handling of sensitive data in hook contexts + +### Performance Considerations + +- Hooks should execute asynchronously to avoid blocking main execution +- Configurable execution modes (parallel vs sequential) +- Resource limits and monitoring for hook execution +- Efficient event routing and filtering + +### Compatibility Considerations + +- Maintain backward compatibility with existing Codex functionality +- Graceful degradation when hooks are disabled or fail +- Clear migration path for existing notification configurations +- Integration with existing MCP and configuration systems + +--- + +## Progress Tracking + +- [x] **Phase 1 Complete**: Core Infrastructure (3/3 sections) ✅ +- [ ] **Phase 2 Complete**: Hook Execution Engine (0/3 sections) +- [ ] **Phase 3 Complete**: Event System Integration (0/3 sections) +- [ ] **Phase 4 Complete**: Client-Side Integration (0/2 sections) +- [ ] **Phase 5 Complete**: Configuration and Documentation (0/3 sections) +- [ ] **Phase 6 Complete**: Testing and Validation (0/4 sections) +- [ ] **Phase 7 Complete**: Advanced Features (0/3 sections) +- [ ] **Phase 8 Complete**: Magentic-One QA Integration (0/4 sections) +- [ ] **Phase 9 Complete**: Comprehensive E2E Testing (0/3 sections) + +**Overall Progress: 3/28 sections complete (10.7%)** + +--- + +## Parallel Development Strategy + +### 👥 Two-Person Development Plan + +To enable parallel development, the remaining work has been split into two independent workstreams: + +#### 🔵 **Developer A: Core Execution Engine** (Backend Focus) + +- Phase 2: Hook Execution Engine +- Phase 3: Event System Integration +- Phase 6: Testing and Validation +- Phase 9: Comprehensive E2E Testing (Backend portions) + +#### 🟢 **Developer B: Client Integration & Documentation** (Frontend/Docs Focus) + +- Phase 4: Client-Side Integration +- Phase 5: Configuration and Documentation +- Phase 7: Advanced Features +- Phase 8: Magentic-One QA Integration +- Phase 9: Comprehensive E2E Testing (Frontend portions) + +### 📋 Task Assignment Details + +See the **WORKSTREAM ASSIGNMENTS** section below for detailed task breakdowns. + +## Getting Started + +**Phase 1 Complete** ✅ - Foundation established with types and registry system. + +**Next Steps**: Choose your workstream and begin with the assigned Phase 2 or Phase 4 tasks. + +--- + +# WORKSTREAM ASSIGNMENTS + +## 🔵 Developer A: Core Execution Engine (Backend Focus) + +### Responsibilities + +- Hook execution coordination and management +- Event system integration with existing Codex architecture +- Core testing infrastructure and validation +- Performance optimization and error handling + +### Primary Files to Work On + +- `codex-rs/core/src/hooks/manager.rs` +- `codex-rs/core/src/hooks/executor.rs` +- `codex-rs/core/src/hooks/executors/` (new directory) +- `codex-rs/core/src/protocol.rs` +- `codex-rs/core/src/codex.rs` +- `codex-rs/core/src/agent.rs` +- `codex-rs/exec/src/event_processor.rs` + +### Assigned Phases + +#### 🔄 **Phase 2: Hook Execution Engine** (Start Here) + +- **2.1 Core Hook Manager** 🔴 HIGH PRIORITY + + - [ ] Complete hook execution coordination in `manager.rs` + - [ ] Implement event subscription and routing + - [ ] Add error handling and logging + - [ ] Performance monitoring and metrics + +- **2.2 Hook Executor Framework** + + - [ ] Complete timeout management and cancellation in `executor.rs` + - [ ] Implement error isolation and recovery + - [ ] Add execution mode support (blocking/non-blocking, parallel/sequential) + - [ ] Hook execution result aggregation + +- **2.3 Hook Executor Implementations** + - [ ] Create `codex-rs/core/src/hooks/executors/mod.rs` + - [ ] Implement `ScriptExecutor` in `executors/script.rs` + - [ ] Implement `WebhookExecutor` in `executors/webhook.rs` + - [ ] Implement `McpToolExecutor` in `executors/mcp.rs` + +#### 🔄 **Phase 3: Event System Integration** + +- **3.1 Protocol Extensions** + + - [ ] Add lifecycle event types to `protocol.rs` + - [ ] Add hook execution events for monitoring + - [ ] Update event serialization/deserialization + +- **3.2 Core Integration Points** + + - [ ] Integrate hook manager in `codex.rs` + - [ ] Add hook trigger points in `agent.rs` + - [ ] Session and task lifecycle hooks + +- **3.3 Execution Integration** + - [ ] Add hook execution to `event_processor.rs` + - [ ] Command execution hooks (before/after) + - [ ] Patch application hooks (before/after) + - [ ] MCP tool execution hooks + +#### 🔄 **Phase 6: Testing and Validation** + +- **6.1 Unit Tests** + + - [ ] Test hook execution coordination + - [ ] Test timeout and error handling + - [ ] Test individual hook executors + +- **6.2 Integration Tests** + + - [ ] Test hook execution with real events + - [ ] Test hook error handling and recovery + - [ ] Test performance impact + +- **6.3 End-to-End Tests** + - [ ] Test complete hook workflows + - [ ] Test integration with existing Codex functionality + +--- + +## 🟢 Developer B: Client Integration & Documentation (Frontend/Docs Focus) + +### Responsibilities + +- Client-side hook support and CLI integration +- Documentation, examples, and user-facing features +- Advanced features and monitoring capabilities +- Configuration templates and user experience + +### Primary Files to Work On + +- `codex-cli/src/utils/agent/agent-loop.ts` +- `docs/` (new directory) +- `examples/` (new directory) +- CLI configuration files +- Documentation and example scripts + +### Assigned Phases + +#### 🔄 **Phase 4: Client-Side Integration** (Start Here) + +- **4.1 TypeScript/CLI Integration** 🔴 HIGH PRIORITY + + - [ ] Add client-side hook support to `agent-loop.ts` + - [ ] Hook event handling in agent loop + - [ ] Client-side hook configuration + - [ ] Hook execution status reporting + +- **4.2 Event Processing Updates** + - [ ] Update CLI configuration to support hooks + - [ ] Command line flags for hook control + - [ ] Hook configuration file discovery + - [ ] Hook status and debugging output + +#### 🔄 **Phase 5: Configuration and Documentation** + +- **5.1 Configuration System** + + - [ ] Create default `hooks.toml` configuration template + - [ ] Add configuration validation and error reporting + - [ ] Support for profile-specific hook configurations + - [ ] Environment-based hook configuration overrides + +- **5.2 Example Hooks and Scripts** + + - [ ] Create `examples/hooks/` directory with example hook scripts + - [ ] Session logging hook example + - [ ] Security scanning hook example + - [ ] Slack notification webhook example + - [ ] File backup hook example + - [ ] Analytics/metrics collection hook example + - [ ] Create hook script templates for common use cases + +- **5.3 Documentation** + - [ ] Create `docs/hooks.md` - Comprehensive hooks documentation + - [ ] Hook system overview and architecture + - [ ] Configuration reference + - [ ] Hook types and executors + - [ ] Security considerations + - [ ] Troubleshooting guide + - [ ] Update main README.md with hooks section + - [ ] Create hook development guide + - [ ] Add API documentation for hook development + +#### 🔄 **Phase 7: Advanced Features** + +- **7.1 Advanced Hook Features** + + - [ ] Hook dependency management and ordering + - [ ] Hook result chaining and data passing + - [ ] Hook execution metrics and monitoring + - [ ] Hook execution history and logging + +- **7.2 Additional Hook Types** + + - [ ] Database hook executor (for logging to databases) + - [ ] Message queue hook executor (for async processing) + - [ ] File system hook executor (for file operations) + - [ ] Custom plugin hook executor (for extensibility) + +- **7.3 Management and Monitoring** + - [ ] Hook execution dashboard/monitoring + - [ ] Hook performance metrics collection + - [ ] Hook error reporting and alerting + - [ ] Hook configuration management tools + +--- + +## 🔄 Coordination Points + +### Shared Dependencies + +Both developers will need to coordinate on these shared components: + +1. **Hook Types** (`types.rs`) - Already complete ✅ +2. **Hook Context** (`context.rs`) - Already complete ✅ +3. **Hook Configuration** (`config.rs`) - Already complete ✅ +4. **Hook Registry** (`registry.rs`) - Already complete ✅ + +### Communication Protocol + +#### Daily Sync Points + +- **Morning Standup**: Share progress and identify any blocking dependencies +- **End of Day**: Commit progress and update TODO checkboxes + +#### Merge Strategy + +- **Developer A**: Create feature branches like `feat/hook-execution-engine` +- **Developer B**: Create feature branches like `feat/hook-client-integration` +- **Regular Merges**: Merge completed phases to avoid large conflicts + +#### Conflict Resolution + +- **File Conflicts**: Developer A owns backend files, Developer B owns frontend/docs +- **Shared Files**: Coordinate changes via GitHub issues or direct communication +- **Testing**: Both developers should run full test suite before merging + +### Branch Strategy + +```bash +# Developer A workflow +git checkout -b feat/hook-execution-engine +# Work on Phase 2 tasks +git commit -m "feat: implement hook execution coordination" +git push origin feat/hook-execution-engine +# Create PR when phase complete + +# Developer B workflow +git checkout -b feat/hook-client-integration +# Work on Phase 4 tasks +git commit -m "feat: add client-side hook support" +git push origin feat/hook-client-integration +# Create PR when phase complete +``` + +### Success Metrics + +#### Developer A Success Criteria + +- [ ] Hooks execute successfully with proper error handling +- [ ] Performance impact < 5% on normal Codex operations +- [ ] All hook types (script, webhook, MCP) working +- [ ] Integration tests passing + +#### Developer B Success Criteria + +- [ ] CLI users can easily configure and use hooks +- [ ] Comprehensive documentation with examples +- [ ] Hook configuration validation and helpful error messages +- [ ] Advanced features enhance user experience + +--- + +## 📊 Progress Tracking + +### Developer A Progress + +- [ ] **Phase 2 Complete**: Hook Execution Engine (0/3 sections) +- [ ] **Phase 3 Complete**: Event System Integration (0/3 sections) +- [ ] **Phase 6 Complete**: Testing and Validation (0/4 sections) +- [ ] **Phase 9 Complete**: E2E Testing - Backend (0/2 sections) + +### Developer B Progress + +- [ ] **Phase 4 Complete**: Client-Side Integration (0/2 sections) +- [ ] **Phase 5 Complete**: Configuration and Documentation (0/3 sections) +- [ ] **Phase 7 Complete**: Advanced Features (0/3 sections) +- [ ] **Phase 8 Complete**: Magentic-One QA Integration (0/4 sections) +- [ ] **Phase 9 Complete**: E2E Testing - Frontend (0/1 section) + +### Overall Project Progress + +- [x] **Phase 1 Complete**: Core Infrastructure (3/3 sections) ✅ +- [ ] **Phases 2-9 Complete**: Parallel Development (0/22 sections) + +**Total Progress: 3/25 sections complete (12%)** diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 309c671e747..027e84868e0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -515,8 +515,10 @@ dependencies = [ "anyhow", "assert_cmd", "async-channel", + "async-trait", "base64 0.21.7", "bytes", + "chrono", "codex-apply-patch", "codex-mcp-client", "dirs", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 46872949818..a1e548bbee2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -13,8 +13,10 @@ workspace = true [dependencies] anyhow = "1" async-channel = "2.3.1" +async-trait = "0.1" base64 = "0.21" bytes = "1.10.1" +chrono = { version = "0.4", features = ["serde"] } codex-apply-patch = { path = "../apply-patch" } codex-mcp-client = { path = "../mcp-client" } dirs = "6" @@ -31,9 +33,11 @@ rand = "0.9" reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" thiserror = "2.0.12" time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tokio = { version = "1", features = [ + "fs", "io-std", "macros", "process", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2699a9ce787..e062f99f40e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -48,6 +48,9 @@ use crate::exec::SandboxType; use crate::exec::process_exec_tool_call; use crate::exec_env::create_env; use crate::flags::OPENAI_STREAM_MAX_RETRIES; +use crate::hooks::manager::HookManager; +use crate::hooks::types::LifecycleEvent; +use crate::hooks::protocol_integration::{ProtocolEventConverter, ProtocolEventEmitter}; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::try_parse_fully_qualified_tool_name; use crate::mcp_tool_call::handle_mcp_tool_call; @@ -95,6 +98,28 @@ pub struct Codex { rx_event: Receiver, } +/// Protocol event emitter implementation for the Codex system. +#[derive(Debug, Clone)] +struct CodexProtocolEventEmitter { + tx_event: Sender, +} + +impl CodexProtocolEventEmitter { + fn new(tx_event: Sender) -> Self { + Self { tx_event } + } +} + +impl ProtocolEventEmitter for CodexProtocolEventEmitter { + fn emit_event(&self, event: Event) { + // Send the event asynchronously, ignoring errors if the receiver is closed + let tx = self.tx_event.clone(); + tokio::spawn(async move { + let _ = tx.send(event).await; + }); + } +} + impl Codex { /// Spawn a new [`Codex`] and initialize the session. Returns the instance /// of `Codex` and the ID of the `SessionInitialized` event that was @@ -463,9 +488,9 @@ pub(crate) struct AgentTask { } impl AgentTask { - fn spawn(sess: Arc, sub_id: String, input: Vec) -> Self { + fn spawn(sess: Arc, sub_id: String, input: Vec, hook_manager: Option>) -> Self { let handle = - tokio::spawn(run_task(Arc::clone(&sess), sub_id.clone(), input)).abort_handle(); + tokio::spawn(run_task(Arc::clone(&sess), sub_id.clone(), input, hook_manager)).abort_handle(); Self { sess, sub_id, @@ -499,6 +524,16 @@ async fn submission_loop( // Generate a unique ID for the lifetime of this Codex session. let session_id = Uuid::new_v4(); + // Initialize hook manager for lifecycle events + let protocol_emitter = CodexProtocolEventEmitter::new(tx_event.clone()); + let hook_manager = match HookManager::new(config.hooks.clone()).await { + Ok(manager) => Some(Arc::new(manager)), + Err(e) => { + warn!("Failed to initialize hook manager: {}", e); + None + } + }; + let mut sess: Option> = None; // shorthand - send an event when there is no active session let send_no_session_event = |sub_id: String| async { @@ -639,7 +674,7 @@ async fn submission_loop( approval_policy, sandbox_policy, shell_environment_policy: config.shell_environment_policy.clone(), - cwd, + cwd: cwd.clone(), writable_roots, mcp_connection_manager, notify, @@ -648,6 +683,34 @@ async fn submission_loop( codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), })); + // Trigger session start lifecycle event + if let Some(ref hook_manager) = hook_manager { + let session_start_event = LifecycleEvent::SessionStart { + session_id: session_id.to_string(), + model: model.clone(), + cwd: cwd.clone(), + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(session_start_event).await { + warn!("Failed to execute session start hooks: {}", e); + } + }); + + // Emit session start protocol event + if let Some(protocol_event) = ProtocolEventConverter::convert_lifecycle_event(&LifecycleEvent::SessionStart { + session_id: session_id.to_string(), + model: model.clone(), + cwd: cwd.clone(), + timestamp: chrono::Utc::now(), + }) { + protocol_emitter.emit_event(protocol_event); + } + } + // Gather history metadata for SessionConfiguredEvent. let (history_log_id, history_entry_count) = crate::message_history::history_metadata(&config).await; @@ -681,7 +744,7 @@ async fn submission_loop( // attempt to inject input into current task if let Err(items) = sess.inject_input(items) { // no current task, spawn a new one - let task = AgentTask::spawn(Arc::clone(sess), sub.id, items); + let task = AgentTask::spawn(Arc::clone(sess), sub.id, items, hook_manager.clone()); sess.set_task(task); } } @@ -757,10 +820,36 @@ async fn submission_loop( } } } + + // Handle session end when the submission loop exits + if let Some(ref hook_manager) = hook_manager { + if let Some(ref _session) = sess { + let session_start_time = std::time::SystemTime::now(); + let session_duration = session_start_time.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + + let session_end_event = LifecycleEvent::SessionEnd { + session_id: session_id.to_string(), + duration: session_duration, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks synchronously since we're shutting down + if let Err(e) = hook_manager.trigger_event(session_end_event.clone()).await { + warn!("Failed to execute session end hooks: {}", e); + } + + // Emit session end protocol event + if let Some(protocol_event) = ProtocolEventConverter::convert_lifecycle_event(&session_end_event) { + protocol_emitter.emit_event(protocol_event); + } + } + } + debug!("Agent loop exited"); } -async fn run_task(sess: Arc, sub_id: String, input: Vec) { +async fn run_task(sess: Arc, sub_id: String, input: Vec, hook_manager: Option>) { if input.is_empty() { return; } @@ -772,6 +861,33 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { return; } + // Trigger task start lifecycle event + let task_start_time = std::time::Instant::now(); + if let Some(ref hook_manager) = hook_manager { + let prompt = input.iter() + .filter_map(|item| match item { + InputItem::Text { text } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" "); + + let task_start_event = LifecycleEvent::TaskStart { + task_id: sub_id.clone(), + session_id: "current".to_string(), // TODO: Get actual session ID + prompt, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(task_start_event).await { + warn!("Failed to execute task start hooks: {}", e); + } + }); + } + let mut pending_response_input: Vec = vec![ResponseInputItem::from(input)]; let last_agent_message: Option; loop { @@ -865,6 +981,28 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { } Err(e) => { info!("Turn error: {e:#}"); + + // Trigger task failure lifecycle event + if let Some(ref hook_manager) = hook_manager { + let task_duration = task_start_time.elapsed(); + let task_complete_event = LifecycleEvent::TaskComplete { + task_id: sub_id.clone(), + session_id: "current".to_string(), // TODO: Get actual session ID + success: false, // Task failed + output: Some(format!("Task failed: {}", e)), + duration: task_duration, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(task_complete_event).await { + warn!("Failed to execute task failure hooks: {}", e); + } + }); + } + let event = Event { id: sub_id.clone(), msg: EventMsg::Error(ErrorEvent { @@ -877,6 +1015,28 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { } } sess.remove_task(&sub_id); + + // Trigger task completion lifecycle event + if let Some(ref hook_manager) = hook_manager { + let task_duration = task_start_time.elapsed(); + let task_complete_event = LifecycleEvent::TaskComplete { + task_id: sub_id.clone(), + session_id: "current".to_string(), // TODO: Get actual session ID + success: true, // Task completed successfully if we reach here + output: last_agent_message.clone(), + duration: task_duration, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(task_complete_event).await { + warn!("Failed to execute task complete hooks: {}", e); + } + }); + } + let event = Event { id: sub_id, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }), diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index d643d006606..674089bb101 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -5,6 +5,7 @@ use crate::config_types::ShellEnvironmentPolicy; use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; +use crate::hooks::config::HooksConfig; use crate::flags::OPENAI_DEFAULT_MODEL; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::built_in_model_providers; @@ -106,6 +107,9 @@ pub struct Config { /// /// When this program is invoked, arg0 will be set to `codex-linux-sandbox`. pub codex_linux_sandbox_exe: Option, + + /// Lifecycle hooks configuration. + pub hooks: HooksConfig, } /// Base config deserialized from ~/.codex/config.toml. @@ -169,6 +173,10 @@ pub struct ConfigToml { /// Collection of settings that are specific to the TUI. pub tui: Option, + + /// Lifecycle hooks configuration. + #[serde(default)] + pub hooks: Option, } impl ConfigToml { @@ -370,6 +378,7 @@ impl Config { file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), tui: cfg.tui.unwrap_or_default(), codex_linux_sandbox_exe, + hooks: cfg.hooks.unwrap_or_default(), }; Ok(config) } @@ -711,6 +720,7 @@ disable_response_storage = true file_opener: UriBasedFileOpener::VsCode, tui: Tui::default(), codex_linux_sandbox_exe: None, + hooks: HooksConfig::default(), }, o3_profile_config ); @@ -750,6 +760,7 @@ disable_response_storage = true file_opener: UriBasedFileOpener::VsCode, tui: Tui::default(), codex_linux_sandbox_exe: None, + hooks: HooksConfig::default(), }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -804,6 +815,7 @@ disable_response_storage = true file_opener: UriBasedFileOpener::VsCode, tui: Tui::default(), codex_linux_sandbox_exe: None, + hooks: HooksConfig::default(), }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/hooks/config.rs b/codex-rs/core/src/hooks/config.rs new file mode 100644 index 00000000000..686639e788e --- /dev/null +++ b/codex-rs/core/src/hooks/config.rs @@ -0,0 +1,354 @@ +//! Hook configuration parsing and validation. + +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::hooks::types::{HookError, HookExecutionMode, HookPriority, HookType, LifecycleEventType}; + +/// Main hooks configuration structure. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct HooksConfig { + /// Global hooks settings. + #[serde(default)] + pub hooks: GlobalHooksConfig, +} + +/// Global hooks configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GlobalHooksConfig { + /// Whether hooks are enabled globally. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Default timeout for hook execution. + #[serde(default = "default_timeout_seconds")] + pub timeout_seconds: u64, + + /// Whether to execute hooks in parallel by default. + #[serde(default = "default_parallel_execution")] + pub parallel_execution: bool, + + /// Session lifecycle hooks. + #[serde(default)] + pub session: Vec, + + /// Task lifecycle hooks. + #[serde(default)] + pub task: Vec, + + /// Execution lifecycle hooks. + #[serde(default)] + pub exec: Vec, + + /// Patch lifecycle hooks. + #[serde(default)] + pub patch: Vec, + + /// MCP tool lifecycle hooks. + #[serde(default)] + pub mcp: Vec, + + /// Agent interaction hooks. + #[serde(default)] + pub agent: Vec, + + /// Error handling hooks. + #[serde(default)] + pub error: Vec, + + /// Custom integration hooks. + #[serde(default)] + pub integration: Vec, +} + +impl Default for GlobalHooksConfig { + fn default() -> Self { + Self { + enabled: default_enabled(), + timeout_seconds: default_timeout_seconds(), + parallel_execution: default_parallel_execution(), + session: Vec::new(), + task: Vec::new(), + exec: Vec::new(), + patch: Vec::new(), + mcp: Vec::new(), + agent: Vec::new(), + error: Vec::new(), + integration: Vec::new(), + } + } +} + +/// Configuration for a single hook. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HookConfig { + /// The lifecycle event that triggers this hook. + pub event: LifecycleEventType, + + /// The type and configuration of the hook. + #[serde(flatten)] + pub hook_type: HookType, + + /// Execution mode for this hook. + #[serde(default)] + pub mode: HookExecutionMode, + + /// Priority for hook execution ordering. + #[serde(default)] + pub priority: HookPriority, + + /// Optional condition for conditional execution. + pub condition: Option, + + /// Whether this hook should block execution if it fails. + #[serde(default)] + pub blocking: bool, + + /// Whether this hook execution is required for the operation to succeed. + #[serde(default)] + pub required: bool, + + /// Tags for hook categorization and filtering. + #[serde(default)] + pub tags: Vec, + + /// Human-readable description of the hook. + pub description: Option, +} + +impl HookConfig { + /// Validate the hook configuration. + pub fn validate(&self) -> Result<(), HookError> { + // Validate hook type specific configuration + match &self.hook_type { + HookType::Script { command, .. } => { + if command.is_empty() { + return Err(HookError::Configuration( + "Script hook must have a non-empty command".to_string(), + )); + } + } + HookType::Webhook { url, .. } => { + if url.is_empty() { + return Err(HookError::Configuration( + "Webhook hook must have a non-empty URL".to_string(), + )); + } + // Basic URL validation + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(HookError::Configuration( + "Webhook URL must start with http:// or https://".to_string(), + )); + } + } + HookType::McpTool { server, tool, .. } => { + if server.is_empty() || tool.is_empty() { + return Err(HookError::Configuration( + "MCP tool hook must have non-empty server and tool names".to_string(), + )); + } + } + HookType::Executable { path, .. } => { + if !path.exists() { + return Err(HookError::Configuration(format!( + "Executable path does not exist: {}", + path.display() + ))); + } + } + } + + // Validate condition syntax if present + if let Some(condition) = &self.condition { + self.validate_condition(condition)?; + } + + Ok(()) + } + + /// Validate condition syntax (basic validation for now). + fn validate_condition(&self, condition: &str) -> Result<(), HookError> { + // Basic validation - ensure condition is not empty + if condition.trim().is_empty() { + return Err(HookError::Configuration( + "Hook condition cannot be empty".to_string(), + )); + } + + // TODO: Implement more sophisticated condition parsing and validation + // For now, we just check for basic syntax + + Ok(()) + } + + /// Get the timeout for this hook, falling back to the provided default. + pub fn get_timeout(&self, default_timeout: Duration) -> Duration { + match &self.hook_type { + HookType::Script { timeout, .. } + | HookType::Webhook { timeout, .. } + | HookType::McpTool { timeout, .. } + | HookType::Executable { timeout, .. } => { + timeout.unwrap_or(default_timeout) + } + } + } +} + +/// Load hooks configuration from a TOML file. +pub fn load_hooks_config(path: &PathBuf) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| HookError::Configuration(format!("Failed to read hooks config: {}", e)))?; + + let config: HooksConfig = toml::from_str(&content) + .map_err(|e| HookError::Configuration(format!("Failed to parse hooks config: {}", e)))?; + + // Validate all hook configurations + validate_hooks_config(&config)?; + + Ok(config) +} + +/// Validate the entire hooks configuration. +pub fn validate_hooks_config(config: &HooksConfig) -> Result<(), HookError> { + let all_hooks = [ + &config.hooks.session, + &config.hooks.task, + &config.hooks.exec, + &config.hooks.patch, + &config.hooks.mcp, + &config.hooks.agent, + &config.hooks.error, + &config.hooks.integration, + ]; + + for hook_group in all_hooks { + for hook in hook_group { + hook.validate()?; + } + } + + Ok(()) +} + +/// Get all hooks for a specific event type from the configuration. +pub fn get_hooks_for_event( + config: &HooksConfig, + event_type: LifecycleEventType, +) -> Vec<&HookConfig> { + let all_hooks = [ + &config.hooks.session, + &config.hooks.task, + &config.hooks.exec, + &config.hooks.patch, + &config.hooks.mcp, + &config.hooks.agent, + &config.hooks.error, + &config.hooks.integration, + ]; + + let mut matching_hooks = Vec::new(); + + for hook_group in all_hooks { + for hook in hook_group { + if hook.event == event_type { + matching_hooks.push(hook); + } + } + } + + // Sort by priority (lower numbers = higher priority) + matching_hooks.sort_by_key(|hook| hook.priority); + + matching_hooks +} + +// Default value functions for serde +fn default_enabled() -> bool { + true +} + +fn default_timeout_seconds() -> u64 { + 30 +} + +fn default_parallel_execution() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_hooks_config_parsing() { + let config_content = r#" +[hooks] +enabled = true +timeout_seconds = 60 + +[[hooks.task]] +event = "task_start" +type = "script" +command = ["echo", "Task started"] +environment = {} +mode = "async" +priority = 100 +"#; + + let config: HooksConfig = toml::from_str(config_content).unwrap(); + assert!(config.hooks.enabled); + assert_eq!(config.hooks.timeout_seconds, 60); + assert_eq!(config.hooks.task.len(), 1); + + let hook = &config.hooks.task[0]; + assert_eq!(hook.event, LifecycleEventType::TaskStart); + assert_eq!(hook.mode, HookExecutionMode::Async); + } + + #[test] + fn test_hook_validation() { + let hook = HookConfig { + event: LifecycleEventType::TaskStart, + hook_type: HookType::Script { + command: vec!["echo".to_string(), "test".to_string()], + cwd: None, + environment: HashMap::new(), + timeout: None, + }, + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + condition: None, + blocking: false, + required: false, + tags: Vec::new(), + description: None, + }; + + assert!(hook.validate().is_ok()); + } + + #[test] + fn test_invalid_hook_validation() { + let hook = HookConfig { + event: LifecycleEventType::TaskStart, + hook_type: HookType::Script { + command: vec![], // Empty command should fail validation + cwd: None, + environment: HashMap::new(), + timeout: None, + }, + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + condition: None, + blocking: false, + required: false, + tags: Vec::new(), + description: None, + }; + + assert!(hook.validate().is_err()); + } +} diff --git a/codex-rs/core/src/hooks/context.rs b/codex-rs/core/src/hooks/context.rs new file mode 100644 index 00000000000..3b634f72b63 --- /dev/null +++ b/codex-rs/core/src/hooks/context.rs @@ -0,0 +1,347 @@ +//! Hook execution context and data management. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::SystemTime; + +use serde::{Deserialize, Serialize}; +use tempfile::NamedTempFile; +use tokio::fs; + +use crate::hooks::types::{HookError, HookType, LifecycleEvent}; + +/// Context provided to hooks during execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookContext { + /// The lifecycle event that triggered this hook. + pub event: LifecycleEvent, + /// Environment variables to be passed to the hook. + pub environment: HashMap, + /// Temporary files created for hook data. + pub temp_files: HashMap, + /// Additional metadata for the hook execution. + pub metadata: HashMap, + /// Working directory for hook execution. + pub working_directory: PathBuf, + /// Hook execution timestamp. + pub execution_timestamp: SystemTime, + /// Type of hook being executed. + pub hook_type: HookType, +} + +impl HookContext { + /// Create a new hook context for the given event. + pub fn new(event: LifecycleEvent, working_directory: PathBuf) -> Self { + // Default hook type - will be set later + let default_hook_type = HookType::Script { + command: vec![], + cwd: None, + environment: HashMap::new(), + timeout: None, + }; + + Self { + event, + environment: HashMap::new(), + temp_files: HashMap::new(), + metadata: HashMap::new(), + working_directory, + execution_timestamp: SystemTime::now(), + hook_type: default_hook_type, + } + } + + /// Set the hook type for this context. + pub fn with_hook_type(mut self, hook_type: HookType) -> Self { + self.hook_type = hook_type; + self + } + + /// Add an environment variable to the context. + pub fn with_env(mut self, key: String, value: String) -> Self { + self.environment.insert(key, value); + self + } + + /// Add multiple environment variables to the context. + pub fn with_env_vars(mut self, vars: HashMap) -> Self { + self.environment.extend(vars); + self + } + + /// Add metadata to the context. + pub fn with_metadata(mut self, key: String, value: serde_json::Value) -> Self { + self.metadata.insert(key, value); + self + } + + /// Get an environment variable from the context. + pub fn get_env(&self, key: &str) -> Option<&String> { + self.environment.get(key) + } + + /// Get metadata from the context. + pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> { + self.metadata.get(key) + } + + /// Create a temporary file with the given content and register it in the context. + pub async fn create_temp_file( + &mut self, + name: &str, + content: &str, + ) -> Result { + let temp_file = NamedTempFile::new() + .map_err(|e| HookError::Context(format!("Failed to create temp file: {}", e)))?; + + let temp_path = temp_file.path().to_path_buf(); + + // Write content to the temporary file + fs::write(&temp_path, content) + .await + .map_err(|e| HookError::Context(format!("Failed to write temp file: {}", e)))?; + + // Keep the temp file alive by storing it + self.temp_files.insert(name.to_string(), temp_path.clone()); + + // Prevent the temp file from being deleted when NamedTempFile is dropped + let _ = temp_file.into_temp_path().keep() + .map_err(|e| HookError::Context(format!("Failed to persist temp file: {}", e)))?; + + Ok(temp_path) + } + + /// Get the path to a temporary file by name. + pub fn get_temp_file(&self, name: &str) -> Option<&PathBuf> { + self.temp_files.get(name) + } + + /// Serialize the event data to JSON. + pub fn event_as_json(&self) -> Result { + serde_json::to_string_pretty(&self.event) + .map_err(|e| HookError::Context(format!("Failed to serialize event: {}", e))) + } + + /// Create a JSON file with the event data. + pub async fn create_event_json_file(&mut self) -> Result { + let json_content = self.event_as_json()?; + self.create_temp_file("event.json", &json_content).await + } + + /// Get all environment variables as a HashMap suitable for process execution. + pub fn get_all_env_vars(&self) -> HashMap { + let mut env_vars = self.environment.clone(); + + // Add standard Codex environment variables + env_vars.insert("CODEX_EVENT_TYPE".to_string(), format!("{:?}", self.event.event_type())); + env_vars.insert("CODEX_TIMESTAMP".to_string(), + self.execution_timestamp.duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default().as_secs().to_string()); + + // Add task ID if available + if let Some(task_id) = self.event.task_id() { + env_vars.insert("CODEX_TASK_ID".to_string(), task_id.to_string()); + } + + // Add event-specific environment variables + match &self.event { + LifecycleEvent::SessionStart { session_id, model, .. } => { + env_vars.insert("CODEX_SESSION_ID".to_string(), session_id.clone()); + env_vars.insert("CODEX_MODEL".to_string(), model.clone()); + } + LifecycleEvent::SessionEnd { session_id, duration, .. } => { + env_vars.insert("CODEX_SESSION_ID".to_string(), session_id.clone()); + env_vars.insert("CODEX_DURATION_SECS".to_string(), duration.as_secs().to_string()); + } + LifecycleEvent::TaskStart { prompt, .. } => { + env_vars.insert("CODEX_PROMPT".to_string(), prompt.clone()); + } + LifecycleEvent::TaskComplete { success, .. } => { + env_vars.insert("CODEX_SUCCESS".to_string(), success.to_string()); + } + LifecycleEvent::ExecBefore { command, .. } => { + env_vars.insert("CODEX_COMMAND".to_string(), command.join(" ")); + } + LifecycleEvent::ExecAfter { command, exit_code, .. } => { + env_vars.insert("CODEX_COMMAND".to_string(), command.join(" ")); + env_vars.insert("CODEX_EXIT_CODE".to_string(), exit_code.to_string()); + } + LifecycleEvent::McpToolBefore { server, tool, .. } => { + env_vars.insert("CODEX_MCP_SERVER".to_string(), server.clone()); + env_vars.insert("CODEX_MCP_TOOL".to_string(), tool.clone()); + } + LifecycleEvent::McpToolAfter { server, tool, success, .. } => { + env_vars.insert("CODEX_MCP_SERVER".to_string(), server.clone()); + env_vars.insert("CODEX_MCP_TOOL".to_string(), tool.clone()); + env_vars.insert("CODEX_SUCCESS".to_string(), success.to_string()); + } + _ => {} + } + + env_vars + } +} + +/// Builder for creating hook execution contexts with fluent API. +pub struct HookExecutionContext { + event: LifecycleEvent, + working_directory: PathBuf, + environment: HashMap, + metadata: HashMap, +} + +impl HookExecutionContext { + /// Create a new hook execution context builder. + pub fn new(event: LifecycleEvent, working_directory: PathBuf) -> Self { + Self { + event, + working_directory, + environment: HashMap::new(), + metadata: HashMap::new(), + } + } + + /// Add an environment variable. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.environment.insert(key.into(), value.into()); + self + } + + /// Add multiple environment variables. + pub fn env_vars(mut self, vars: HashMap) -> Self { + self.environment.extend(vars); + self + } + + /// Add metadata. + pub fn metadata(mut self, key: impl Into, value: serde_json::Value) -> Self { + self.metadata.insert(key.into(), value); + self + } + + /// Build the hook context. + pub fn build(self) -> HookContext { + HookContext::new(self.event, self.working_directory) + .with_env_vars(self.environment) + .with_metadata("builder_metadata".to_string(), serde_json::json!(self.metadata)) + } +} + +/// Template variable substitution for hook configurations. +pub struct TemplateSubstitution { + variables: HashMap, +} + +impl TemplateSubstitution { + /// Create a new template substitution from a hook context. + pub fn from_context(context: &HookContext) -> Self { + let mut variables = HashMap::new(); + + // Add environment variables + for (key, value) in &context.environment { + variables.insert(format!("env.{}", key), value.clone()); + } + + // Add event-specific variables + match &context.event { + LifecycleEvent::SessionStart { session_id, model, .. } => { + variables.insert("session_id".to_string(), session_id.clone()); + variables.insert("model".to_string(), model.clone()); + } + LifecycleEvent::TaskStart { task_id, prompt, .. } => { + variables.insert("task_id".to_string(), task_id.clone()); + variables.insert("prompt".to_string(), prompt.clone()); + } + LifecycleEvent::ExecBefore { call_id, command, .. } => { + variables.insert("call_id".to_string(), call_id.clone()); + variables.insert("command".to_string(), command.join(" ")); + } + LifecycleEvent::ExecAfter { call_id, command, exit_code, .. } => { + variables.insert("call_id".to_string(), call_id.clone()); + variables.insert("command".to_string(), command.join(" ")); + variables.insert("exit_code".to_string(), exit_code.to_string()); + } + _ => {} + } + + // Add temp file paths + for (name, path) in &context.temp_files { + variables.insert(format!("temp.{}", name), path.to_string_lossy().to_string()); + } + + Self { variables } + } + + /// Substitute template variables in a string. + pub fn substitute(&self, template: &str) -> String { + let mut result = template.to_string(); + + for (key, value) in &self.variables { + let placeholder = format!("{{{}}}", key); + result = result.replace(&placeholder, value); + } + + result + } + + /// Substitute template variables in a vector of strings. + pub fn substitute_vec(&self, templates: &[String]) -> Vec { + templates.iter().map(|t| self.substitute(t)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + #[test] + fn test_hook_context_creation() { + let event = LifecycleEvent::TaskStart { + task_id: "test".to_string(), + session_id: "session".to_string(), + prompt: "test prompt".to_string(), + timestamp: Utc::now(), + }; + + let context = HookContext::new(event, PathBuf::from("/tmp")) + .with_env("TEST_VAR".to_string(), "test_value".to_string()); + + assert_eq!(context.get_env("TEST_VAR"), Some(&"test_value".to_string())); + assert_eq!(context.working_directory, PathBuf::from("/tmp")); + } + + #[test] + fn test_hook_execution_context_builder() { + let event = LifecycleEvent::TaskStart { + task_id: "test".to_string(), + session_id: "session".to_string(), + prompt: "test prompt".to_string(), + timestamp: Utc::now(), + }; + + let context = HookExecutionContext::new(event, PathBuf::from("/tmp")) + .env("TEST_VAR", "test_value") + .metadata("test_key", serde_json::json!("test_value")) + .build(); + + assert_eq!(context.get_env("TEST_VAR"), Some(&"test_value".to_string())); + } + + #[test] + fn test_template_substitution() { + let event = LifecycleEvent::TaskStart { + task_id: "test_task".to_string(), + session_id: "session".to_string(), + prompt: "test prompt".to_string(), + timestamp: Utc::now(), + }; + + let context = HookContext::new(event, PathBuf::from("/tmp")); + let substitution = TemplateSubstitution::from_context(&context); + + let template = "Task {task_id} started"; + let result = substitution.substitute(template); + assert_eq!(result, "Task test_task started"); + } +} diff --git a/codex-rs/core/src/hooks/executor.rs b/codex-rs/core/src/hooks/executor.rs new file mode 100644 index 00000000000..09f681114bc --- /dev/null +++ b/codex-rs/core/src/hooks/executor.rs @@ -0,0 +1,866 @@ +//! Hook execution framework and base executor. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use futures::future::join_all; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +use crate::hooks::context::HookContext; +use crate::hooks::types::{HookError, HookResult, HookExecutionMode, HookPriority}; + +/// Result type for hook executor operations. +pub type HookExecutorResult = Result; + +/// Execution configuration for hook execution. +#[derive(Debug, Clone)] +pub struct ExecutionConfig { + /// Maximum execution time before timeout. + pub timeout: Duration, + /// Execution mode (blocking, async, fire-and-forget). + pub mode: HookExecutionMode, + /// Priority for execution ordering. + pub priority: HookPriority, + /// Whether this hook is required (failure stops execution). + pub required: bool, + /// Maximum number of retry attempts on failure. + pub max_retries: u32, + /// Delay between retry attempts. + pub retry_delay: Duration, + /// Whether to isolate execution in a separate task. + pub isolated: bool, +} + +impl Default for ExecutionConfig { + fn default() -> Self { + Self { + timeout: Duration::from_secs(30), + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + required: false, + max_retries: 0, + retry_delay: Duration::from_millis(500), + isolated: true, + } + } +} + +/// Execution context with cancellation support. +#[derive(Debug, Clone)] +pub struct ExecutionContext { + /// Unique execution ID for tracking. + pub execution_id: String, + /// Hook context with event data. + pub hook_context: HookContext, + /// Execution configuration. + pub config: ExecutionConfig, + /// Start time for performance tracking. + pub start_time: Instant, + /// Cancellation token. + pub cancelled: Arc>, +} + +impl ExecutionContext { + /// Create a new execution context. + pub fn new(hook_context: HookContext, config: ExecutionConfig) -> Self { + Self { + execution_id: format!("exec_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()), + hook_context, + config, + start_time: Instant::now(), + cancelled: Arc::new(RwLock::new(false)), + } + } + + /// Check if execution has been cancelled. + pub async fn is_cancelled(&self) -> bool { + *self.cancelled.read().await + } + + /// Cancel the execution. + pub async fn cancel(&self) { + *self.cancelled.write().await = true; + } + + /// Get elapsed execution time. + pub fn elapsed(&self) -> Duration { + self.start_time.elapsed() + } +} + +/// Result of hook execution with detailed information. +#[derive(Debug, Clone)] +pub struct ExecutionResult { + /// Execution ID for tracking. + pub execution_id: String, + /// Hook execution result. + pub result: HookResult, + /// Execution configuration used. + pub config: ExecutionConfig, + /// Total execution time. + pub duration: Duration, + /// Number of retry attempts made. + pub retry_attempts: u32, + /// Whether execution was cancelled. + pub cancelled: bool, + /// Error details if execution failed. + pub error_details: Option, +} + +/// Aggregated results from multiple hook executions. +#[derive(Debug, Clone, Default)] +pub struct AggregatedResults { + /// All execution results. + pub results: Vec, + /// Successfully completed executions. + pub successful: Vec, + /// Failed executions. + pub failed: Vec, + /// Cancelled executions. + pub cancelled: Vec, + /// Total execution time for all hooks. + pub total_duration: Duration, + /// Average execution time. + pub average_duration: Duration, + /// Success rate (0.0 to 1.0). + pub success_rate: f64, +} + +impl AggregatedResults { + /// Create aggregated results from individual execution results. + pub fn from_results(results: Vec) -> Self { + let total_count = results.len(); + let successful: Vec<_> = results.iter().filter(|r| r.result.success && !r.cancelled).cloned().collect(); + let failed: Vec<_> = results.iter().filter(|r| !r.result.success && !r.cancelled).cloned().collect(); + let cancelled: Vec<_> = results.iter().filter(|r| r.cancelled).cloned().collect(); + + let total_duration = results.iter().map(|r| r.duration).sum(); + let average_duration = if total_count > 0 { + total_duration / total_count as u32 + } else { + Duration::ZERO + }; + + let success_rate = if total_count > 0 { + successful.len() as f64 / total_count as f64 + } else { + 0.0 + }; + + Self { + results, + successful, + failed, + cancelled, + total_duration, + average_duration, + success_rate, + } + } + + /// Check if any critical (required) hooks failed. + pub fn has_critical_failures(&self) -> bool { + self.failed.iter().any(|r| r.config.required) + } + + /// Get summary statistics. + pub fn summary(&self) -> String { + format!( + "Executed {} hooks: {} successful, {} failed, {} cancelled (success rate: {:.1}%)", + self.results.len(), + self.successful.len(), + self.failed.len(), + self.cancelled.len(), + self.success_rate * 100.0 + ) + } +} + +/// Enhanced trait for hook executors with advanced execution capabilities. +#[async_trait] +pub trait HookExecutor: Send + Sync { + /// Execute a hook with the given context. + async fn execute(&self, context: &HookContext) -> HookExecutorResult; + + /// Execute a hook with full execution context and configuration. + async fn execute_with_context(&self, exec_context: &ExecutionContext) -> ExecutionResult { + let start_time = Instant::now(); + let execution_id = exec_context.execution_id.clone(); + let config = exec_context.config.clone(); + + debug!("Starting hook execution: {} ({})", execution_id, self.executor_type()); + + // Check if already cancelled + if exec_context.is_cancelled().await { + return ExecutionResult { + execution_id, + result: HookResult::failure("Execution cancelled before start".to_string(), Duration::ZERO), + config, + duration: start_time.elapsed(), + retry_attempts: 0, + cancelled: true, + error_details: Some("Pre-execution cancellation".to_string()), + }; + } + + let mut retry_attempts = 0; + let mut last_error = None; + + // Retry loop + loop { + // Check for cancellation before each attempt + if exec_context.is_cancelled().await { + return ExecutionResult { + execution_id, + result: HookResult::failure("Execution cancelled".to_string(), start_time.elapsed()), + config, + duration: start_time.elapsed(), + retry_attempts, + cancelled: true, + error_details: Some("Mid-execution cancellation".to_string()), + }; + } + + // Execute with timeout + let execution_future = self.execute(&exec_context.hook_context); + let _result: Result = match timeout(config.timeout, execution_future).await { + Ok(Ok(hook_result)) => { + debug!("Hook execution successful: {} (attempt {})", execution_id, retry_attempts + 1); + return ExecutionResult { + execution_id, + result: hook_result, + config, + duration: start_time.elapsed(), + retry_attempts, + cancelled: false, + error_details: None, + }; + } + Ok(Err(e)) => { + warn!("Hook execution failed: {} - {} (attempt {})", execution_id, e, retry_attempts + 1); + last_error = Some(e.to_string()); + Err(e) + } + Err(_) => { + warn!("Hook execution timed out: {} after {:?} (attempt {})", execution_id, config.timeout, retry_attempts + 1); + let timeout_error = format!("Execution timed out after {:?}", config.timeout); + last_error = Some(timeout_error.clone()); + Err(HookError::Execution(timeout_error)) + } + }; + + retry_attempts += 1; + + // Check if we should retry + if retry_attempts > config.max_retries { + break; + } + + // Wait before retry + if config.retry_delay > Duration::ZERO { + tokio::time::sleep(config.retry_delay).await; + } + } + + // All retries exhausted + let error_msg = last_error.unwrap_or_else(|| "Unknown error".to_string()); + ExecutionResult { + execution_id, + result: HookResult::failure(error_msg.clone(), start_time.elapsed()), + config, + duration: start_time.elapsed(), + retry_attempts: retry_attempts.saturating_sub(1), + cancelled: false, + error_details: Some(error_msg), + } + } + + /// Get the name/type of this executor for logging and debugging. + fn executor_type(&self) -> &'static str; + + /// Validate that this executor can handle the given context. + fn can_execute(&self, context: &HookContext) -> bool; + + /// Get the estimated execution time for this hook (for timeout planning). + fn estimated_duration(&self) -> Option { + None + } + + /// Get the default execution configuration for this executor. + fn default_config(&self) -> ExecutionConfig { + ExecutionConfig::default() + } + + /// Prepare for execution (setup, validation, etc.). + async fn prepare(&self, _context: &HookContext) -> Result<(), HookError> { + Ok(()) + } + + /// Cleanup after execution (regardless of success/failure). + async fn cleanup(&self, _context: &HookContext) -> Result<(), HookError> { + Ok(()) + } +} + +/// Advanced execution coordinator that manages multiple hook executions. +#[derive(Debug)] +pub struct ExecutionCoordinator { + /// Active executions being tracked. + active_executions: Arc>>>, + /// Global execution statistics. + stats: Arc>, +} + +/// Global execution statistics. +#[derive(Debug, Clone, Default)] +pub struct ExecutionStats { + pub total_executions: u64, + pub successful_executions: u64, + pub failed_executions: u64, + pub cancelled_executions: u64, + pub total_execution_time: Duration, + pub average_execution_time: Duration, +} + +impl ExecutionCoordinator { + /// Create a new execution coordinator. + pub fn new() -> Self { + Self { + active_executions: Arc::new(Mutex::new(HashMap::new())), + stats: Arc::new(RwLock::new(ExecutionStats::default())), + } + } + + /// Execute multiple hooks with different execution modes. + pub async fn execute_hooks( + &self, + executions: Vec<(Arc, ExecutionContext)>, + ) -> AggregatedResults { + info!("Starting coordinated execution of {} hooks", executions.len()); + + // Separate executions by mode + let (blocking, async_hooks, fire_and_forget): (Vec<_>, Vec<_>, Vec<_>) = executions + .into_iter() + .fold((Vec::new(), Vec::new(), Vec::new()), |mut acc, (executor, context)| { + match context.config.mode { + HookExecutionMode::Blocking => acc.0.push((executor, context)), + HookExecutionMode::Async => acc.1.push((executor, context)), + HookExecutionMode::FireAndForget => acc.2.push((executor, context)), + } + acc + }); + + let mut all_results = Vec::new(); + + // Execute blocking hooks sequentially + for (executor, context) in blocking { + let result = self.execute_single_tracked(executor, context).await; + all_results.push(result); + } + + // Execute async hooks in parallel + if !async_hooks.is_empty() { + let async_futures: Vec<_> = async_hooks + .into_iter() + .map(|(executor, context)| self.execute_single_tracked(executor, context)) + .collect(); + + let async_results = join_all(async_futures).await; + all_results.extend(async_results); + } + + // Execute fire-and-forget hooks (don't wait for completion) + for (executor, context) in fire_and_forget { + let coordinator = self.clone(); + tokio::spawn(async move { + let _result = coordinator.execute_single_tracked(executor, context).await; + // Fire-and-forget results are not included in aggregated results + }); + } + + // Update global statistics + self.update_stats(&all_results).await; + + let aggregated = AggregatedResults::from_results(all_results); + info!("Coordinated execution completed: {}", aggregated.summary()); + aggregated + } + + /// Execute a single hook with tracking. + async fn execute_single_tracked( + &self, + executor: Arc, + context: ExecutionContext, + ) -> ExecutionResult { + let execution_id = context.execution_id.clone(); + + // Track active execution + { + let mut active = self.active_executions.lock().await; + active.insert(execution_id.clone(), Arc::new(context.clone())); + } + + // Prepare for execution + if let Err(e) = executor.prepare(&context.hook_context).await { + warn!("Hook preparation failed: {} - {}", execution_id, e); + return ExecutionResult { + execution_id: execution_id.clone(), + result: HookResult::failure(format!("Preparation failed: {}", e), Duration::ZERO), + config: context.config.clone(), + duration: Duration::ZERO, + retry_attempts: 0, + cancelled: false, + error_details: Some(format!("Preparation error: {}", e)), + }; + } + + // Execute the hook + let result = if context.config.isolated { + // Execute in isolated task + let executor_clone = executor.clone(); + let context_clone = context.clone(); + tokio::spawn(async move { + executor_clone.execute_with_context(&context_clone).await + }) + .await + .unwrap_or_else(|e| ExecutionResult { + execution_id: execution_id.clone(), + result: HookResult::failure(format!("Task join error: {}", e), context.elapsed()), + config: context.config.clone(), + duration: context.elapsed(), + retry_attempts: 0, + cancelled: false, + error_details: Some(format!("Isolation error: {}", e)), + }) + } else { + // Execute directly + executor.execute_with_context(&context).await + }; + + // Cleanup after execution + if let Err(e) = executor.cleanup(&context.hook_context).await { + warn!("Hook cleanup failed: {} - {}", execution_id, e); + } + + // Remove from active executions + { + let mut active = self.active_executions.lock().await; + active.remove(&execution_id); + } + + result + } + + /// Cancel a specific execution. + pub async fn cancel_execution(&self, execution_id: &str) -> bool { + let active = self.active_executions.lock().await; + if let Some(context) = active.get(execution_id) { + context.cancel().await; + true + } else { + false + } + } + + /// Cancel all active executions. + pub async fn cancel_all(&self) { + let active = self.active_executions.lock().await; + for context in active.values() { + context.cancel().await; + } + } + + /// Get list of active execution IDs. + pub async fn get_active_executions(&self) -> Vec { + let active = self.active_executions.lock().await; + active.keys().cloned().collect() + } + + /// Update global execution statistics. + async fn update_stats(&self, results: &[ExecutionResult]) { + let mut stats = self.stats.write().await; + + for result in results { + stats.total_executions += 1; + stats.total_execution_time += result.duration; + + if result.cancelled { + stats.cancelled_executions += 1; + } else if result.result.success { + stats.successful_executions += 1; + } else { + stats.failed_executions += 1; + } + } + + // Update average + if stats.total_executions > 0 { + stats.average_execution_time = stats.total_execution_time / stats.total_executions as u32; + } + } + + /// Get current execution statistics. + pub async fn get_stats(&self) -> ExecutionStats { + self.stats.read().await.clone() + } +} + +impl Clone for ExecutionCoordinator { + fn clone(&self) -> Self { + Self { + active_executions: self.active_executions.clone(), + stats: self.stats.clone(), + } + } +} + +impl Default for ExecutionCoordinator { + fn default() -> Self { + Self::new() + } +} + +// Re-export executors from the executors module +pub use crate::hooks::executors::{ScriptExecutor, WebhookExecutor, McpToolExecutor}; + +// Placeholder implementation for ExecutableExecutor - will be implemented later +pub struct ExecutableExecutor; + + + +#[async_trait] +impl HookExecutor for ExecutableExecutor { + async fn execute(&self, _context: &HookContext) -> HookExecutorResult { + // TODO: Implement in Phase 2.3 + Err(HookError::Execution("ExecutableExecutor not yet implemented".to_string())) + } + + fn executor_type(&self) -> &'static str { + "executable" + } + + fn can_execute(&self, _context: &HookContext) -> bool { + // TODO: Implement in Phase 2.3 + false + } + + fn estimated_duration(&self) -> Option { + Some(Duration::from_secs(30)) // Executables can vary widely + } + + fn default_config(&self) -> ExecutionConfig { + ExecutionConfig { + timeout: Duration::from_secs(300), // 5 minutes for custom executables + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + required: false, + max_retries: 1, + retry_delay: Duration::from_secs(1), + isolated: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::types::LifecycleEvent; + + use tokio::time::sleep; + + // Mock executor for testing + struct MockExecutor { + should_fail: bool, + execution_time: Duration, + call_count: Arc>, + } + + impl MockExecutor { + fn new(should_fail: bool, execution_time: Duration) -> Self { + Self { + should_fail, + execution_time, + call_count: Arc::new(Mutex::new(0)), + } + } + + async fn get_call_count(&self) -> u32 { + *self.call_count.lock().await + } + } + + #[async_trait] + impl HookExecutor for MockExecutor { + async fn execute(&self, _context: &HookContext) -> HookExecutorResult { + { + let mut count = self.call_count.lock().await; + *count += 1; + } + + sleep(self.execution_time).await; + + if self.should_fail { + Err(HookError::Execution("Mock execution failed".to_string())) + } else { + Ok(HookResult::success(Some("Mock success".to_string()), self.execution_time)) + } + } + + fn executor_type(&self) -> &'static str { + "mock" + } + + fn can_execute(&self, _context: &HookContext) -> bool { + true + } + + fn estimated_duration(&self) -> Option { + Some(self.execution_time) + } + } + + fn create_test_context() -> HookContext { + let event = LifecycleEvent::SessionStart { + session_id: "test-session".to_string(), + model: "test-model".to_string(), + cwd: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/tmp")), + timestamp: chrono::Utc::now(), + }; + HookContext::new(event, std::path::PathBuf::from("/tmp")) + } + + #[tokio::test] + async fn test_execution_config_default() { + let config = ExecutionConfig::default(); + assert_eq!(config.timeout, Duration::from_secs(30)); + assert_eq!(config.mode, HookExecutionMode::Async); + assert_eq!(config.priority, HookPriority::NORMAL); + assert!(!config.required); + assert_eq!(config.max_retries, 0); + assert!(config.isolated); + } + + #[tokio::test] + async fn test_execution_context_creation() { + let hook_context = create_test_context(); + let config = ExecutionConfig::default(); + let exec_context = ExecutionContext::new(hook_context, config.clone()); + + assert!(!exec_context.execution_id.is_empty()); + assert_eq!(exec_context.config.timeout, config.timeout); + assert!(!exec_context.is_cancelled().await); + } + + #[tokio::test] + async fn test_execution_context_cancellation() { + let hook_context = create_test_context(); + let config = ExecutionConfig::default(); + let exec_context = ExecutionContext::new(hook_context, config); + + assert!(!exec_context.is_cancelled().await); + exec_context.cancel().await; + assert!(exec_context.is_cancelled().await); + } + + #[tokio::test] + async fn test_successful_execution() { + let executor = MockExecutor::new(false, Duration::from_millis(100)); + let hook_context = create_test_context(); + let config = ExecutionConfig::default(); + let exec_context = ExecutionContext::new(hook_context, config); + + let result = executor.execute_with_context(&exec_context).await; + + assert!(result.result.success); + assert!(!result.cancelled); + assert_eq!(result.retry_attempts, 0); + assert!(result.duration >= Duration::from_millis(100)); + assert_eq!(executor.get_call_count().await, 1); + } + + #[tokio::test] + async fn test_failed_execution_with_retries() { + let executor = MockExecutor::new(true, Duration::from_millis(50)); + let hook_context = create_test_context(); + let mut config = ExecutionConfig::default(); + config.max_retries = 2; + config.retry_delay = Duration::from_millis(10); + let exec_context = ExecutionContext::new(hook_context, config); + + let result = executor.execute_with_context(&exec_context).await; + + assert!(!result.result.success); + assert!(!result.cancelled); + assert_eq!(result.retry_attempts, 2); // 2 retries after initial failure + assert_eq!(executor.get_call_count().await, 3); // Initial + 2 retries + } + + #[tokio::test] + async fn test_execution_timeout() { + let executor = MockExecutor::new(false, Duration::from_millis(200)); + let hook_context = create_test_context(); + let mut config = ExecutionConfig::default(); + config.timeout = Duration::from_millis(50); // Shorter than execution time + let exec_context = ExecutionContext::new(hook_context, config); + + let result = executor.execute_with_context(&exec_context).await; + + assert!(!result.result.success); + assert!(!result.cancelled); + assert!(result.error_details.unwrap().contains("timed out")); + } + + #[tokio::test] + async fn test_execution_cancellation() { + let executor = MockExecutor::new(false, Duration::from_millis(200)); + let hook_context = create_test_context(); + let config = ExecutionConfig::default(); + let exec_context = ExecutionContext::new(hook_context, config); + + // Cancel before execution + exec_context.cancel().await; + let result = executor.execute_with_context(&exec_context).await; + + assert!(!result.result.success); + assert!(result.cancelled); + assert_eq!(result.retry_attempts, 0); + assert_eq!(executor.get_call_count().await, 0); // Should not execute + } + + #[tokio::test] + async fn test_aggregated_results() { + let results = vec![ + ExecutionResult { + execution_id: "1".to_string(), + result: HookResult::success(Some("success".to_string()), Duration::from_millis(100)), + config: ExecutionConfig::default(), + duration: Duration::from_millis(100), + retry_attempts: 0, + cancelled: false, + error_details: None, + }, + ExecutionResult { + execution_id: "2".to_string(), + result: HookResult::failure("failed".to_string(), Duration::from_millis(50)), + config: ExecutionConfig::default(), + duration: Duration::from_millis(50), + retry_attempts: 1, + cancelled: false, + error_details: Some("error".to_string()), + }, + ]; + + let aggregated = AggregatedResults::from_results(results); + + assert_eq!(aggregated.results.len(), 2); + assert_eq!(aggregated.successful.len(), 1); + assert_eq!(aggregated.failed.len(), 1); + assert_eq!(aggregated.cancelled.len(), 0); + assert_eq!(aggregated.success_rate, 0.5); + assert_eq!(aggregated.total_duration, Duration::from_millis(150)); + assert_eq!(aggregated.average_duration, Duration::from_millis(75)); + } + + #[tokio::test] + async fn test_execution_coordinator() { + let coordinator = ExecutionCoordinator::new(); + + let executor1 = Arc::new(MockExecutor::new(false, Duration::from_millis(50))); + let executor2 = Arc::new(MockExecutor::new(true, Duration::from_millis(30))); + + let hook_context = create_test_context(); + let config1 = ExecutionConfig { + mode: HookExecutionMode::Async, + ..ExecutionConfig::default() + }; + let config2 = ExecutionConfig { + mode: HookExecutionMode::Async, + ..ExecutionConfig::default() + }; + + let executions = vec![ + (executor1.clone() as Arc, ExecutionContext::new(hook_context.clone(), config1)), + (executor2.clone() as Arc, ExecutionContext::new(hook_context, config2)), + ]; + + let results = coordinator.execute_hooks(executions).await; + + assert_eq!(results.results.len(), 2); + assert_eq!(results.successful.len(), 1); + assert_eq!(results.failed.len(), 1); + assert_eq!(results.success_rate, 0.5); + + // Check statistics were updated + let stats = coordinator.get_stats().await; + assert_eq!(stats.total_executions, 2); + assert_eq!(stats.successful_executions, 1); + assert_eq!(stats.failed_executions, 1); + } + + #[tokio::test] + async fn test_execution_coordinator_cancellation() { + let coordinator = ExecutionCoordinator::new(); + + let executor = Arc::new(MockExecutor::new(false, Duration::from_millis(1000))); // Longer execution time + let hook_context = create_test_context(); + let config = ExecutionConfig::default(); + let exec_context = ExecutionContext::new(hook_context, config); + let execution_id = exec_context.execution_id.clone(); + + let executions = vec![ + (executor.clone() as Arc, exec_context), + ]; + + // Start execution in background + let coordinator_clone = coordinator.clone(); + let execution_task = tokio::spawn(async move { + coordinator_clone.execute_hooks(executions).await + }); + + // Wait a bit to ensure execution has started, then cancel + sleep(Duration::from_millis(100)).await; + let cancelled = coordinator.cancel_execution(&execution_id).await; + + // Wait for execution to complete + let results = execution_task.await.unwrap(); + + // Either the execution was cancelled or it completed before cancellation + // Both are valid outcomes for this test + assert_eq!(results.results.len(), 1); + if cancelled { + // If we successfully cancelled, check that cancellation was handled + // (The result could be in cancelled list or completed before cancellation) + assert!(results.results.len() == 1); // Should have exactly one result + } + // The important thing is that the coordinator handled the cancellation request properly + } + + #[test] + fn test_executor_default_configs() { + let script = ScriptExecutor::new(); + let webhook = WebhookExecutor::new(); + let mcp = McpToolExecutor::new(); + let executable = ExecutableExecutor; + + assert_eq!(script.executor_type(), "script"); + assert_eq!(webhook.executor_type(), "webhook"); + assert_eq!(mcp.executor_type(), "mcp_tool"); + assert_eq!(executable.executor_type(), "executable"); + + // Test estimated durations + assert_eq!(script.estimated_duration(), Some(Duration::from_secs(5))); + assert_eq!(webhook.estimated_duration(), Some(Duration::from_secs(10))); + assert_eq!(mcp.estimated_duration(), Some(Duration::from_secs(15))); + assert_eq!(executable.estimated_duration(), Some(Duration::from_secs(30))); + + // Test default configs have appropriate timeouts + assert_eq!(script.default_config().timeout, Duration::from_secs(30)); + assert_eq!(webhook.default_config().timeout, Duration::from_secs(60)); + assert_eq!(mcp.default_config().timeout, Duration::from_secs(120)); + assert_eq!(executable.default_config().timeout, Duration::from_secs(300)); + } +} \ No newline at end of file diff --git a/codex-rs/core/src/hooks/executors/mcp.rs b/codex-rs/core/src/hooks/executors/mcp.rs new file mode 100644 index 00000000000..4c145b628d0 --- /dev/null +++ b/codex-rs/core/src/hooks/executors/mcp.rs @@ -0,0 +1,537 @@ +//! MCP tool executor for calling MCP tools as hooks. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tracing::{debug, error, info, warn}; + +use crate::hooks::context::HookContext; +use crate::hooks::executor::{ExecutionConfig, HookExecutor, HookExecutorResult}; +use crate::hooks::types::{HookError, HookExecutionMode, HookPriority, HookResult, HookType}; + +/// Executor for calling MCP tools as hooks. +#[derive(Debug, Clone)] +pub struct McpToolExecutor { + /// Default timeout for MCP tool calls. + default_timeout: Duration, + /// Maximum response size to capture. + max_response_size: usize, + /// Default server configuration. + default_server: Option, +} + +impl Default for McpToolExecutor { + fn default() -> Self { + Self::new() + } +} + +impl McpToolExecutor { + /// Create a new MCP tool executor with default settings. + pub fn new() -> Self { + Self { + default_timeout: Duration::from_secs(60), + max_response_size: 1024 * 1024, // 1MB default + default_server: None, + } + } + + /// Create an MCP tool executor with custom timeout. + pub fn with_timeout(timeout: Duration) -> Self { + Self { + default_timeout: timeout, + max_response_size: 1024 * 1024, + default_server: None, + } + } + + /// Set default MCP server to use. + pub fn with_default_server>(mut self, server: S) -> Self { + self.default_server = Some(server.into()); + self + } + + /// Set maximum response size to capture. + pub fn with_max_response_size(mut self, size: usize) -> Self { + self.max_response_size = size; + self + } + + /// Extract MCP tool configuration from hook context. + fn extract_mcp_config(&self, context: &HookContext) -> Result { + match &context.hook_type { + HookType::McpTool { server, tool, timeout: _ } => { + if tool.is_empty() { + return Err(HookError::Configuration("MCP tool name cannot be empty".to_string())); + } + + let server_name = if server.is_empty() { + self.default_server.clone() + .ok_or_else(|| HookError::Configuration( + "MCP server must be specified either in hook config or executor default".to_string() + ))? + } else { + server.clone() + }; + + Ok(McpConfig { + tool_name: tool.clone(), + server: server_name, + arguments: HashMap::new(), // No arguments in the hook type, use empty map + }) + } + _ => Err(HookError::Configuration( + "McpToolExecutor can only execute McpTool hooks".to_string(), + )), + } + } + + /// Build arguments for MCP tool call from hook context. + fn build_tool_arguments(&self, context: &HookContext, base_args: &HashMap) -> HashMap { + let mut args = base_args.clone(); + + // Add context information as arguments + args.insert("codex_event_type".to_string(), json!(context.event.event_type().to_string())); + args.insert("codex_timestamp".to_string(), json!(chrono::Utc::now().to_rfc3339())); + args.insert("codex_working_dir".to_string(), json!(context.working_directory.to_string_lossy())); + + // Add event-specific arguments + match &context.event { + crate::hooks::types::LifecycleEvent::SessionStart { session_id, model, cwd, .. } => { + args.insert("session_id".to_string(), json!(session_id)); + args.insert("model".to_string(), json!(model)); + args.insert("cwd".to_string(), json!(cwd.to_string_lossy())); + } + crate::hooks::types::LifecycleEvent::SessionEnd { session_id, duration, .. } => { + args.insert("session_id".to_string(), json!(session_id)); + args.insert("duration_ms".to_string(), json!(duration.as_millis())); + } + crate::hooks::types::LifecycleEvent::TaskStart { task_id, session_id, prompt, .. } => { + args.insert("task_id".to_string(), json!(task_id)); + args.insert("session_id".to_string(), json!(session_id)); + args.insert("prompt".to_string(), json!(prompt)); + } + crate::hooks::types::LifecycleEvent::TaskComplete { task_id, session_id, success, duration, .. } => { + args.insert("task_id".to_string(), json!(task_id)); + args.insert("session_id".to_string(), json!(session_id)); + args.insert("success".to_string(), json!(success)); + args.insert("duration_ms".to_string(), json!(duration.as_millis())); + } + crate::hooks::types::LifecycleEvent::ExecBefore { command, cwd, .. } => { + args.insert("command".to_string(), json!(command)); + args.insert("exec_cwd".to_string(), json!(cwd.to_string_lossy())); + } + crate::hooks::types::LifecycleEvent::ExecAfter { command, exit_code, duration, .. } => { + args.insert("command".to_string(), json!(command)); + args.insert("exit_code".to_string(), json!(exit_code)); + args.insert("duration_ms".to_string(), json!(duration.as_millis())); + } + crate::hooks::types::LifecycleEvent::PatchBefore { changes, .. } => { + args.insert("changes".to_string(), json!(changes)); + } + crate::hooks::types::LifecycleEvent::PatchAfter { applied_files, success, .. } => { + args.insert("applied_files".to_string(), json!(applied_files)); + args.insert("success".to_string(), json!(success)); + } + crate::hooks::types::LifecycleEvent::McpToolBefore { server, tool, .. } => { + args.insert("server".to_string(), json!(server)); + args.insert("tool".to_string(), json!(tool)); + } + crate::hooks::types::LifecycleEvent::McpToolAfter { server, tool, success, duration, .. } => { + args.insert("server".to_string(), json!(server)); + args.insert("tool".to_string(), json!(tool)); + args.insert("success".to_string(), json!(success)); + args.insert("duration_ms".to_string(), json!(duration.as_millis())); + } + crate::hooks::types::LifecycleEvent::AgentMessage { message, reasoning, .. } => { + args.insert("message".to_string(), json!(message)); + if let Some(reasoning) = reasoning { + args.insert("reasoning".to_string(), json!(reasoning)); + } + } + crate::hooks::types::LifecycleEvent::ErrorOccurred { error, context: error_context, .. } => { + args.insert("error".to_string(), json!(error)); + args.insert("error_context".to_string(), json!(error_context)); + } + } + + // Add environment variables as arguments + for (key, value) in &context.environment { + args.insert(format!("env_{}", key.to_lowercase()), json!(value)); + } + + args + } + + /// Execute MCP tool call. + async fn execute_mcp_tool(&self, config: McpConfig, arguments: HashMap) -> Result { + let start_time = Instant::now(); + + debug!("Calling MCP tool: {} on server: {}", config.tool_name, config.server); + + // TODO: This is a placeholder implementation. In a real implementation, this would: + // 1. Connect to the specified MCP server + // 2. Call the tool with the provided arguments + // 3. Handle the response and any errors + // 4. Return the result + + // For now, we'll simulate the MCP tool call + let result = self.simulate_mcp_call(&config, &arguments).await?; + + let duration = start_time.elapsed(); + + debug!( + "MCP tool call completed: tool={}, success={}, duration={:?}", + config.tool_name, result.success, duration + ); + + Ok(McpResult { + tool_name: config.tool_name, + server: config.server, + success: result.success, + result: result.result, + error: result.error, + duration, + arguments, + }) + } + + /// Simulate MCP tool call (placeholder implementation). + async fn simulate_mcp_call(&self, config: &McpConfig, arguments: &HashMap) -> Result { + // This is a placeholder that simulates different MCP tool behaviors + // In a real implementation, this would be replaced with actual MCP client calls + + tokio::time::sleep(Duration::from_millis(100)).await; // Simulate network delay + + match config.tool_name.as_str() { + "log_event" => { + // Simulate a logging tool that always succeeds + Ok(SimulatedMcpResult { + success: true, + result: Some(json!({ + "logged": true, + "event_type": arguments.get("codex_event_type"), + "timestamp": arguments.get("codex_timestamp") + })), + error: None, + }) + } + "validate_data" => { + // Simulate a validation tool that sometimes fails + let has_required_field = arguments.contains_key("task_id") || arguments.contains_key("session_id"); + if has_required_field { + Ok(SimulatedMcpResult { + success: true, + result: Some(json!({"validation": "passed", "fields_checked": arguments.len()})), + error: None, + }) + } else { + Ok(SimulatedMcpResult { + success: false, + result: None, + error: Some("Missing required fields for validation".to_string()), + }) + } + } + "send_notification" => { + // Simulate a notification tool + Ok(SimulatedMcpResult { + success: true, + result: Some(json!({ + "notification_sent": true, + "recipient": "default", + "message": format!("Codex event: {}", arguments.get("codex_event_type").unwrap_or(&json!("unknown"))) + })), + error: None, + }) + } + "failing_tool" => { + // Simulate a tool that always fails + Ok(SimulatedMcpResult { + success: false, + result: None, + error: Some("This tool is designed to fail for testing purposes".to_string()), + }) + } + _ => { + // Unknown tool + Err(HookError::Execution(format!("Unknown MCP tool: {}", config.tool_name))) + } + } + } +} + +#[async_trait] +impl HookExecutor for McpToolExecutor { + async fn execute(&self, context: &HookContext) -> HookExecutorResult { + let start_time = Instant::now(); + + info!("Executing MCP tool hook for event: {:?}", context.event.event_type()); + + // Extract MCP configuration + let mcp_config = self.extract_mcp_config(context)?; + + // Build tool arguments + let arguments = self.build_tool_arguments(context, &mcp_config.arguments); + + // Execute the MCP tool + match self.execute_mcp_tool(mcp_config, arguments).await { + Ok(result) => { + if result.success { + info!( + "MCP tool hook executed successfully: tool={}, duration={:?}", + result.tool_name, result.duration + ); + + let output = Some(format!( + "MCP tool '{}' executed successfully on server '{}': {:?}", + result.tool_name, result.server, result.result + )); + + Ok(HookResult::success(output, start_time.elapsed())) + } else { + warn!( + "MCP tool hook failed: tool={}, error={}", + result.tool_name, + result.error.as_deref().unwrap_or("Unknown error") + ); + + let error_msg = format!( + "MCP tool '{}' failed: {}", + result.tool_name, + result.error.as_deref().unwrap_or("Unknown error") + ); + + Ok(HookResult::failure(error_msg, start_time.elapsed())) + } + } + Err(e) => { + error!("MCP tool hook execution error: {}", e); + Ok(HookResult::failure(e.to_string(), start_time.elapsed())) + } + } + } + + fn executor_type(&self) -> &'static str { + "mcp_tool" + } + + fn can_execute(&self, context: &HookContext) -> bool { + matches!(context.hook_type, HookType::McpTool { .. }) + } + + fn estimated_duration(&self) -> Option { + Some(Duration::from_secs(15)) // MCP tools can be complex + } + + fn default_config(&self) -> ExecutionConfig { + ExecutionConfig { + timeout: Duration::from_secs(120), + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + required: false, + max_retries: 2, + retry_delay: Duration::from_secs(2), + isolated: true, + } + } + + async fn prepare(&self, context: &HookContext) -> Result<(), HookError> { + // Validate MCP configuration + let _config = self.extract_mcp_config(context)?; + + // TODO: In a real implementation, this would: + // 1. Check if the MCP server is available + // 2. Verify that the tool exists on the server + // 3. Validate the tool's schema if available + + Ok(()) + } + + async fn cleanup(&self, _context: &HookContext) -> Result<(), HookError> { + // TODO: In a real implementation, this might: + // 1. Close any open MCP connections + // 2. Clean up temporary resources + // 3. Log cleanup completion + + Ok(()) + } +} + +/// Configuration for MCP tool execution. +#[derive(Debug, Clone)] +struct McpConfig { + /// Name of the MCP tool to call. + tool_name: String, + /// MCP server to call the tool on. + server: String, + /// Arguments to pass to the tool. + arguments: HashMap, +} + +/// Result of MCP tool execution. +#[derive(Debug, Clone)] +struct McpResult { + /// Name of the tool that was called. + tool_name: String, + /// Server the tool was called on. + server: String, + /// Whether the tool call was successful. + success: bool, + /// Result data from the tool. + result: Option, + /// Error message if the tool failed. + error: Option, + /// Duration of the tool call. + duration: Duration, + /// Arguments that were passed to the tool. + arguments: HashMap, +} + +/// Simulated MCP result (placeholder). +#[derive(Debug, Clone)] +struct SimulatedMcpResult { + success: bool, + result: Option, + error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::types::LifecycleEvent; + use std::path::PathBuf; + + fn create_test_context(tool_name: String, server: Option) -> HookContext { + let event = LifecycleEvent::SessionStart { + session_id: "test-session".to_string(), + model: "test-model".to_string(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp")), + timestamp: chrono::Utc::now(), + }; + + let hook_type = HookType::McpTool { + server: server.unwrap_or_default(), + tool: tool_name, + timeout: None, + }; + + HookContext::new(event, PathBuf::from("/tmp")) + .with_hook_type(hook_type) + } + + #[tokio::test] + async fn test_mcp_executor_creation() { + let executor = McpToolExecutor::new(); + assert_eq!(executor.executor_type(), "mcp_tool"); + assert_eq!(executor.default_timeout, Duration::from_secs(60)); + assert_eq!(executor.max_response_size, 1024 * 1024); + } + + #[tokio::test] + async fn test_mcp_executor_with_default_server() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + assert_eq!(executor.default_server, Some("test-server".to_string())); + } + + #[tokio::test] + async fn test_mcp_executor_can_execute() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + let context = create_test_context("log_event".to_string(), None); + + assert!(executor.can_execute(&context)); + } + + #[tokio::test] + async fn test_successful_mcp_tool_execution() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + let context = create_test_context("log_event".to_string(), None); + + let result = executor.execute(&context).await.unwrap(); + + assert!(result.success); + assert!(result.output.is_some()); + assert!(result.output.unwrap().contains("log_event")); + } + + #[tokio::test] + async fn test_failed_mcp_tool_execution() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + let context = create_test_context("failing_tool".to_string(), None); + + let result = executor.execute(&context).await.unwrap(); + + assert!(!result.success); + assert!(result.error.is_some()); + assert!(result.error.unwrap().contains("designed to fail")); + } + + #[tokio::test] + async fn test_mcp_tool_with_validation() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + let context = create_test_context("validate_data".to_string(), None); + + let result = executor.execute(&context).await.unwrap(); + + // Should succeed because SessionStart event includes session_id + assert!(result.success); + assert!(result.output.is_some()); + } + + #[tokio::test] + async fn test_mcp_tool_arguments_building() { + let executor = McpToolExecutor::new(); + let context = create_test_context("log_event".to_string(), Some("test-server".to_string())); + + let base_args = HashMap::new(); + let args = executor.build_tool_arguments(&context, &base_args); + + assert!(args.contains_key("codex_event_type")); + assert!(args.contains_key("codex_timestamp")); + assert!(args.contains_key("session_id")); + assert!(args.contains_key("model")); + assert_eq!(args["session_id"], json!("test-session")); + assert_eq!(args["model"], json!("test-model")); + } + + #[tokio::test] + async fn test_mcp_preparation() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + let context = create_test_context("log_event".to_string(), None); + + let result = executor.prepare(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_mcp_cleanup() { + let executor = McpToolExecutor::new().with_default_server("test-server"); + let context = create_test_context("log_event".to_string(), None); + + let result = executor.cleanup(&context).await; + assert!(result.is_ok()); + } + + #[test] + fn test_mcp_executor_default_config() { + let executor = McpToolExecutor::new(); + let config = executor.default_config(); + + assert_eq!(config.timeout, Duration::from_secs(120)); + assert_eq!(config.mode, HookExecutionMode::Async); + assert_eq!(config.max_retries, 2); + assert!(config.isolated); + } + + #[tokio::test] + async fn test_missing_server_configuration() { + let executor = McpToolExecutor::new(); // No default server + let context = create_test_context("log_event".to_string(), None); // No server in hook + + let result = executor.extract_mcp_config(&context); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("server must be specified")); + } +} diff --git a/codex-rs/core/src/hooks/executors/mod.rs b/codex-rs/core/src/hooks/executors/mod.rs new file mode 100644 index 00000000000..5a6ca64a59c --- /dev/null +++ b/codex-rs/core/src/hooks/executors/mod.rs @@ -0,0 +1,12 @@ +//! Hook executor implementations for different hook types. + +pub mod script; +pub mod webhook; +pub mod mcp; + +pub use script::ScriptExecutor; +pub use webhook::WebhookExecutor; +pub use mcp::McpToolExecutor; + +// Re-export the ExecutableExecutor from the executor module +pub use crate::hooks::executor::ExecutableExecutor; diff --git a/codex-rs/core/src/hooks/executors/script.rs b/codex-rs/core/src/hooks/executors/script.rs new file mode 100644 index 00000000000..0407c8aefef --- /dev/null +++ b/codex-rs/core/src/hooks/executors/script.rs @@ -0,0 +1,502 @@ +//! Script executor for running shell scripts and commands as hooks. + +use std::collections::HashMap; + +use std::path::PathBuf; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tracing::{debug, error, info, warn}; + +use crate::hooks::context::HookContext; +use crate::hooks::executor::{ExecutionConfig, HookExecutor, HookExecutorResult}; +use crate::hooks::types::{HookError, HookExecutionMode, HookPriority, HookResult, HookType}; + +/// Executor for running shell scripts and commands. +#[derive(Debug, Clone)] +pub struct ScriptExecutor { + /// Default shell to use for script execution. + pub default_shell: String, + /// Default working directory for script execution. + pub default_working_dir: Option, + /// Environment variables to always include. + pub base_environment: HashMap, + /// Maximum output size to capture (in bytes). + pub max_output_size: usize, +} + +impl Default for ScriptExecutor { + fn default() -> Self { + Self::new() + } +} + +impl ScriptExecutor { + /// Create a new script executor with default settings. + pub fn new() -> Self { + let default_shell = if cfg!(windows) { + "cmd".to_string() + } else { + "bash".to_string() + }; + + Self { + default_shell, + default_working_dir: None, + base_environment: HashMap::new(), + max_output_size: 1024 * 1024, // 1MB default + } + } + + /// Create a script executor with custom shell. + pub fn with_shell>(shell: S) -> Self { + Self { + default_shell: shell.into(), + ..Self::new() + } + } + + /// Set the default working directory. + pub fn with_working_dir>(mut self, dir: P) -> Self { + self.default_working_dir = Some(dir.into()); + self + } + + /// Add base environment variables. + pub fn with_environment(mut self, env: HashMap) -> Self { + self.base_environment = env; + self + } + + /// Set maximum output size to capture. + pub fn with_max_output_size(mut self, size: usize) -> Self { + self.max_output_size = size; + self + } + + /// Extract script configuration from hook context. + fn extract_script_config(&self, context: &HookContext) -> Result { + match &context.hook_type { + HookType::Script { command, cwd, environment, timeout: _ } => { + if command.is_empty() { + return Err(HookError::Configuration("Script command cannot be empty".to_string())); + } + + Ok(ScriptConfig { + command: command.clone(), + environment: environment.clone(), + working_dir: cwd.clone(), + shell: self.default_shell.clone(), + }) + } + _ => Err(HookError::Configuration( + "ScriptExecutor can only execute Script hooks".to_string(), + )), + } + } + + /// Build the complete environment for script execution. + fn build_environment(&self, context: &HookContext, script_env: &HashMap) -> HashMap { + let mut env = self.base_environment.clone(); + + // Add context environment variables + for (key, value) in &context.environment { + env.insert(key.clone(), value.clone()); + } + + // Add script-specific environment variables + for (key, value) in script_env { + env.insert(key.clone(), value.clone()); + } + + // Add hook execution metadata + env.insert("CODEX_HOOK_TYPE".to_string(), "script".to_string()); + env.insert("CODEX_EVENT_TYPE".to_string(), context.event.event_type().to_string()); + env.insert("CODEX_WORKING_DIR".to_string(), context.working_directory.to_string_lossy().to_string()); + + // Add timestamp + env.insert("CODEX_TIMESTAMP".to_string(), chrono::Utc::now().to_rfc3339()); + + env + } + + /// Execute a script command with the given configuration. + async fn execute_script(&self, config: ScriptConfig, environment: HashMap) -> Result { + let start_time = Instant::now(); + + debug!("Executing script command: {:?}", config.command); + + // Determine working directory + let working_dir = config.working_dir + .or_else(|| self.default_working_dir.clone()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))); + + // Build command + let mut cmd = if cfg!(windows) { + let mut cmd = Command::new("cmd"); + cmd.args(["/C"]); + cmd.args(&config.command); + cmd + } else { + let mut cmd = Command::new(&config.shell); + cmd.arg("-c"); + cmd.arg(config.command.join(" ")); + cmd + }; + + // Set working directory and environment + cmd.current_dir(&working_dir); + cmd.envs(&environment); + + // Configure stdio + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + cmd.stdin(Stdio::null()); + + // Spawn the process + let mut child = cmd.spawn().map_err(|e| { + HookError::Execution(format!("Failed to spawn script process: {}", e)) + })?; + + // Capture output + let stdout = child.stdout.take().ok_or_else(|| { + HookError::Execution("Failed to capture stdout".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + HookError::Execution("Failed to capture stderr".to_string()) + })?; + + // Read output streams + let stdout_task = tokio::spawn(Self::read_stream(stdout, self.max_output_size)); + let stderr_task = tokio::spawn(Self::read_stream(stderr, self.max_output_size)); + + // Wait for process completion + let exit_status = child.wait().await.map_err(|e| { + HookError::Execution(format!("Failed to wait for script process: {}", e)) + })?; + + // Collect output + let stdout_output = stdout_task.await.map_err(|e| { + HookError::Execution(format!("Failed to read stdout: {}", e)) + })??; + let stderr_output = stderr_task.await.map_err(|e| { + HookError::Execution(format!("Failed to read stderr: {}", e)) + })??; + + let duration = start_time.elapsed(); + let exit_code = exit_status.code().unwrap_or(-1); + let success = exit_status.success(); + + debug!( + "Script execution completed: exit_code={}, success={}, duration={:?}", + exit_code, success, duration + ); + + Ok(ScriptResult { + exit_code, + success, + stdout: stdout_output, + stderr: stderr_output, + duration, + command: config.command, + working_dir, + }) + } + + /// Read from a stream with size limit. + async fn read_stream(reader: R, max_size: usize) -> Result + where + R: tokio::io::AsyncRead + Unpin, + { + let mut buf_reader = BufReader::new(reader); + let mut output = String::new(); + let mut line = String::new(); + + while buf_reader.read_line(&mut line).await.map_err(|e| { + HookError::Execution(format!("Failed to read line: {}", e)) + })? > 0 { + if output.len() + line.len() > max_size { + output.push_str("... [output truncated due to size limit]\n"); + break; + } + output.push_str(&line); + line.clear(); + } + + Ok(output) + } +} + +#[async_trait] +impl HookExecutor for ScriptExecutor { + async fn execute(&self, context: &HookContext) -> HookExecutorResult { + let start_time = Instant::now(); + + info!("Executing script hook for event: {:?}", context.event.event_type()); + + // Extract script configuration + let script_config = self.extract_script_config(context)?; + + // Build environment + let environment = self.build_environment(context, &script_config.environment); + + // Execute the script + match self.execute_script(script_config, environment).await { + Ok(result) => { + if result.success { + info!( + "Script hook executed successfully: exit_code={}, duration={:?}", + result.exit_code, result.duration + ); + + let output = if !result.stdout.is_empty() { + Some(result.stdout) + } else if !result.stderr.is_empty() { + Some(result.stderr) + } else { + Some(format!("Script completed with exit code {}", result.exit_code)) + }; + + Ok(HookResult::success(output, start_time.elapsed())) + } else { + warn!( + "Script hook failed: exit_code={}, stderr={}", + result.exit_code, + result.stderr.trim() + ); + + let error_msg = if !result.stderr.is_empty() { + format!("Script failed with exit code {}: {}", result.exit_code, result.stderr.trim()) + } else { + format!("Script failed with exit code {}", result.exit_code) + }; + + Ok(HookResult::failure(error_msg, start_time.elapsed())) + } + } + Err(e) => { + error!("Script hook execution error: {}", e); + Ok(HookResult::failure(e.to_string(), start_time.elapsed())) + } + } + } + + fn executor_type(&self) -> &'static str { + "script" + } + + fn can_execute(&self, context: &HookContext) -> bool { + matches!(context.hook_type, HookType::Script { .. }) + } + + fn estimated_duration(&self) -> Option { + Some(Duration::from_secs(5)) // Scripts typically run quickly + } + + fn default_config(&self) -> ExecutionConfig { + ExecutionConfig { + timeout: Duration::from_secs(30), + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + required: false, + max_retries: 1, + retry_delay: Duration::from_millis(500), + isolated: true, + } + } + + async fn prepare(&self, context: &HookContext) -> Result<(), HookError> { + // Validate script configuration + let _config = self.extract_script_config(context)?; + + // Check if shell exists (basic validation) + if !cfg!(windows) { + let shell_check = Command::new("which") + .arg(&self.default_shell) + .output() + .await; + + if let Ok(output) = shell_check { + if !output.status.success() { + return Err(HookError::Configuration( + format!("Shell '{}' not found in PATH", self.default_shell) + )); + } + } + } + + Ok(()) + } + + async fn cleanup(&self, _context: &HookContext) -> Result<(), HookError> { + // No cleanup needed for script execution + Ok(()) + } +} + +/// Configuration for script execution. +#[derive(Debug, Clone)] +struct ScriptConfig { + /// Command to execute (as array of arguments). + command: Vec, + /// Environment variables for the script. + environment: HashMap, + /// Working directory for script execution. + working_dir: Option, + /// Shell to use for execution. + shell: String, +} + +/// Result of script execution. +#[derive(Debug, Clone)] +struct ScriptResult { + /// Exit code of the script. + exit_code: i32, + /// Whether the script executed successfully. + success: bool, + /// Standard output from the script. + stdout: String, + /// Standard error from the script. + stderr: String, + /// Duration of script execution. + duration: Duration, + /// Command that was executed. + command: Vec, + /// Working directory where script was executed. + working_dir: PathBuf, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::types::LifecycleEvent; + use std::collections::HashMap; + + fn create_test_context(command: Vec) -> HookContext { + let event = LifecycleEvent::SessionStart { + session_id: "test-session".to_string(), + model: "test-model".to_string(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp")), + timestamp: chrono::Utc::now(), + }; + + let hook_type = HookType::Script { + command, + cwd: None, + environment: HashMap::new(), + timeout: None, + }; + + HookContext::new(event, PathBuf::from("/tmp")) + .with_hook_type(hook_type) + } + + #[tokio::test] + async fn test_script_executor_creation() { + let executor = ScriptExecutor::new(); + assert_eq!(executor.executor_type(), "script"); + assert_eq!(executor.max_output_size, 1024 * 1024); + + if cfg!(windows) { + assert_eq!(executor.default_shell, "cmd"); + } else { + assert_eq!(executor.default_shell, "bash"); + } + } + + #[tokio::test] + async fn test_script_executor_with_custom_shell() { + let executor = ScriptExecutor::with_shell("zsh"); + assert_eq!(executor.default_shell, "zsh"); + } + + #[tokio::test] + async fn test_script_executor_can_execute() { + let executor = ScriptExecutor::new(); + let context = create_test_context(vec!["echo".to_string(), "test".to_string()]); + + assert!(executor.can_execute(&context)); + } + + #[tokio::test] + async fn test_successful_script_execution() { + let executor = ScriptExecutor::new(); + let command = if cfg!(windows) { + vec!["echo".to_string(), "Hello World".to_string()] + } else { + vec!["echo".to_string(), "Hello World".to_string()] + }; + let context = create_test_context(command); + + let result = executor.execute(&context).await.unwrap(); + + assert!(result.success); + assert!(result.output.is_some()); + assert!(result.output.unwrap().contains("Hello World")); + } + + #[tokio::test] + async fn test_failed_script_execution() { + let executor = ScriptExecutor::new(); + let command = if cfg!(windows) { + vec!["cmd".to_string(), "/C".to_string(), "exit".to_string(), "1".to_string()] + } else { + vec!["false".to_string()] // Command that always fails + }; + let context = create_test_context(command); + + let result = executor.execute(&context).await.unwrap(); + + assert!(!result.success); + assert!(result.error.is_some()); + } + + #[tokio::test] + async fn test_script_environment_variables() { + let executor = ScriptExecutor::new(); + let command = if cfg!(windows) { + vec!["echo".to_string(), "%CODEX_HOOK_TYPE%".to_string()] + } else { + vec!["echo".to_string(), "$CODEX_HOOK_TYPE".to_string()] + }; + let context = create_test_context(command); + + let result = executor.execute(&context).await.unwrap(); + + assert!(result.success); + assert!(result.output.is_some()); + assert!(result.output.unwrap().contains("script")); + } + + #[tokio::test] + async fn test_script_preparation() { + let executor = ScriptExecutor::new(); + let context = create_test_context(vec!["echo".to_string(), "test".to_string()]); + + let result = executor.prepare(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_script_cleanup() { + let executor = ScriptExecutor::new(); + let context = create_test_context(vec!["echo".to_string(), "test".to_string()]); + + let result = executor.cleanup(&context).await; + assert!(result.is_ok()); + } + + #[test] + fn test_script_executor_default_config() { + let executor = ScriptExecutor::new(); + let config = executor.default_config(); + + assert_eq!(config.timeout, Duration::from_secs(30)); + assert_eq!(config.mode, HookExecutionMode::Async); + assert_eq!(config.max_retries, 1); + assert!(config.isolated); + } +} diff --git a/codex-rs/core/src/hooks/executors/webhook.rs b/codex-rs/core/src/hooks/executors/webhook.rs new file mode 100644 index 00000000000..9d87c7e3e3b --- /dev/null +++ b/codex-rs/core/src/hooks/executors/webhook.rs @@ -0,0 +1,512 @@ +//! Webhook executor for sending HTTP requests as hooks. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use reqwest::{Client, Method, RequestBuilder}; +use serde_json::{json, Value}; +use tracing::{debug, error, info, warn}; + +use crate::hooks::context::HookContext; +use crate::hooks::executor::{ExecutionConfig, HookExecutor, HookExecutorResult}; +use crate::hooks::types::{HookError, HookExecutionMode, HookPriority, HookResult, HookType}; + +/// Executor for sending HTTP webhook requests. +#[derive(Debug, Clone)] +pub struct WebhookExecutor { + /// HTTP client for making requests. + client: Client, + /// Default timeout for HTTP requests. + default_timeout: Duration, + /// Maximum response size to capture. + max_response_size: usize, + /// Default headers to include in all requests. + default_headers: HashMap, +} + +impl Default for WebhookExecutor { + fn default() -> Self { + Self::new() + } +} + +impl WebhookExecutor { + /// Create a new webhook executor with default settings. + pub fn new() -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(60)) + .user_agent("Codex-Hooks/1.0") + .build() + .expect("Failed to create HTTP client"); + + Self { + client, + default_timeout: Duration::from_secs(30), + max_response_size: 1024 * 1024, // 1MB default + default_headers: HashMap::new(), + } + } + + /// Create a webhook executor with custom timeout. + pub fn with_timeout(timeout: Duration) -> Self { + let client = Client::builder() + .timeout(timeout) + .user_agent("Codex-Hooks/1.0") + .build() + .expect("Failed to create HTTP client"); + + Self { + client, + default_timeout: timeout, + max_response_size: 1024 * 1024, + default_headers: HashMap::new(), + } + } + + /// Add default headers to include in all requests. + pub fn with_default_headers(mut self, headers: HashMap) -> Self { + self.default_headers = headers; + self + } + + /// Set maximum response size to capture. + pub fn with_max_response_size(mut self, size: usize) -> Self { + self.max_response_size = size; + self + } + + /// Extract webhook configuration from hook context. + fn extract_webhook_config(&self, context: &HookContext) -> Result { + match &context.hook_type { + HookType::Webhook { url, method, headers, timeout: _, retry_count: _ } => { + if url.is_empty() { + return Err(HookError::Configuration("Webhook URL cannot be empty".to_string())); + } + + // Convert HttpMethod to reqwest::Method + let http_method = match method { + crate::hooks::types::HttpMethod::Get => Method::GET, + crate::hooks::types::HttpMethod::Post => Method::POST, + crate::hooks::types::HttpMethod::Put => Method::PUT, + crate::hooks::types::HttpMethod::Patch => Method::PATCH, + crate::hooks::types::HttpMethod::Delete => Method::DELETE, + }; + + Ok(WebhookConfig { + url: url.clone(), + method: http_method, + headers: headers.clone(), + body: None, // We'll use the generated payload + auth: None, // TODO: Add auth support later + }) + } + _ => Err(HookError::Configuration( + "WebhookExecutor can only execute Webhook hooks".to_string(), + )), + } + } + + /// Build the request payload from hook context. + fn build_payload(&self, context: &HookContext) -> Value { + let mut payload = json!({ + "event": { + "type": context.event.event_type().to_string(), + "timestamp": chrono::Utc::now().to_rfc3339(), + }, + "hook": { + "type": "webhook", + "working_dir": context.working_directory.to_string_lossy(), + }, + "environment": context.environment, + }); + + // Add event-specific data + match &context.event { + crate::hooks::types::LifecycleEvent::SessionStart { session_id, model, cwd, .. } => { + payload["event"]["session_id"] = json!(session_id); + payload["event"]["model"] = json!(model); + payload["event"]["cwd"] = json!(cwd.to_string_lossy()); + } + crate::hooks::types::LifecycleEvent::SessionEnd { session_id, duration, .. } => { + payload["event"]["session_id"] = json!(session_id); + payload["event"]["duration_ms"] = json!(duration.as_millis()); + } + crate::hooks::types::LifecycleEvent::TaskStart { task_id, session_id, prompt, .. } => { + payload["event"]["task_id"] = json!(task_id); + payload["event"]["session_id"] = json!(session_id); + payload["event"]["prompt"] = json!(prompt); + } + crate::hooks::types::LifecycleEvent::TaskComplete { task_id, session_id, success, duration, .. } => { + payload["event"]["task_id"] = json!(task_id); + payload["event"]["session_id"] = json!(session_id); + payload["event"]["success"] = json!(success); + payload["event"]["duration_ms"] = json!(duration.as_millis()); + } + crate::hooks::types::LifecycleEvent::ExecBefore { command, cwd, .. } => { + payload["event"]["command"] = json!(command); + payload["event"]["cwd"] = json!(cwd.to_string_lossy()); + } + crate::hooks::types::LifecycleEvent::ExecAfter { command, exit_code, duration, .. } => { + payload["event"]["command"] = json!(command); + payload["event"]["exit_code"] = json!(exit_code); + payload["event"]["duration_ms"] = json!(duration.as_millis()); + } + crate::hooks::types::LifecycleEvent::PatchBefore { changes, .. } => { + payload["event"]["changes"] = json!(changes); + } + crate::hooks::types::LifecycleEvent::PatchAfter { applied_files, success, .. } => { + payload["event"]["applied_files"] = json!(applied_files); + payload["event"]["success"] = json!(success); + } + crate::hooks::types::LifecycleEvent::McpToolBefore { server, tool, .. } => { + payload["event"]["server"] = json!(server); + payload["event"]["tool"] = json!(tool); + } + crate::hooks::types::LifecycleEvent::McpToolAfter { server, tool, success, duration, .. } => { + payload["event"]["server"] = json!(server); + payload["event"]["tool"] = json!(tool); + payload["event"]["success"] = json!(success); + payload["event"]["duration_ms"] = json!(duration.as_millis()); + } + crate::hooks::types::LifecycleEvent::AgentMessage { message, reasoning, .. } => { + payload["event"]["message"] = json!(message); + if let Some(reasoning) = reasoning { + payload["event"]["reasoning"] = json!(reasoning); + } + } + crate::hooks::types::LifecycleEvent::ErrorOccurred { error, context: error_context, .. } => { + payload["event"]["error"] = json!(error); + payload["event"]["error_context"] = json!(error_context); + } + } + + payload + } + + /// Build HTTP request with configuration and payload. + fn build_request(&self, config: &WebhookConfig, payload: Value) -> Result { + let mut request = self.client.request(config.method.clone(), &config.url); + + // Add default headers + for (key, value) in &self.default_headers { + request = request.header(key, value); + } + + // Add webhook-specific headers + for (key, value) in &config.headers { + request = request.header(key, value); + } + + // Set content type if not specified + if !config.headers.contains_key("content-type") && !self.default_headers.contains_key("content-type") { + request = request.header("content-type", "application/json"); + } + + // Add authentication + if let Some(auth) = &config.auth { + request = match auth { + WebhookAuth::Bearer { token } => request.bearer_auth(token), + WebhookAuth::Basic { username, password } => request.basic_auth(username, password.as_ref()), + WebhookAuth::Header { name, value } => request.header(name, value), + }; + } + + // Add body + let body_value = if let Some(custom_body) = &config.body { + // Use custom body if provided + custom_body.clone() + } else { + // Use generated payload + payload + }; + + request = request.json(&body_value); + + Ok(request) + } + + /// Execute webhook request and handle response. + async fn execute_webhook(&self, config: WebhookConfig, payload: Value) -> Result { + let start_time = Instant::now(); + + debug!("Sending webhook to: {} {}", config.method, config.url); + + // Build request + let request = self.build_request(&config, payload)?; + + // Send request + let response = request.send().await.map_err(|e| { + HookError::Execution(format!("Failed to send webhook request: {}", e)) + })?; + + let status = response.status(); + let headers = response.headers().clone(); + + // Read response body with size limit + let response_text = if response.content_length().unwrap_or(0) > self.max_response_size as u64 { + "[Response too large, truncated]".to_string() + } else { + response.text().await.map_err(|e| { + HookError::Execution(format!("Failed to read response body: {}", e)) + })? + }; + + let duration = start_time.elapsed(); + let success = status.is_success(); + + debug!( + "Webhook response: status={}, success={}, duration={:?}", + status, success, duration + ); + + Ok(WebhookResult { + status_code: status.as_u16(), + success, + response_body: response_text, + response_headers: headers.iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(), + duration, + url: config.url, + method: config.method, + }) + } +} + +#[async_trait] +impl HookExecutor for WebhookExecutor { + async fn execute(&self, context: &HookContext) -> HookExecutorResult { + let start_time = Instant::now(); + + info!("Executing webhook hook for event: {:?}", context.event.event_type()); + + // Extract webhook configuration + let webhook_config = self.extract_webhook_config(context)?; + + // Build payload + let payload = self.build_payload(context); + + // Execute the webhook + match self.execute_webhook(webhook_config, payload).await { + Ok(result) => { + if result.success { + info!( + "Webhook hook executed successfully: status={}, duration={:?}", + result.status_code, result.duration + ); + + let output = Some(format!( + "Webhook sent successfully: {} {} (status: {})", + result.method, result.url, result.status_code + )); + + Ok(HookResult::success(output, start_time.elapsed())) + } else { + warn!( + "Webhook hook failed: status={}, response={}", + result.status_code, + result.response_body.chars().take(200).collect::() + ); + + let error_msg = format!( + "Webhook failed with status {}: {}", + result.status_code, + result.response_body.chars().take(500).collect::() + ); + + Ok(HookResult::failure(error_msg, start_time.elapsed())) + } + } + Err(e) => { + error!("Webhook hook execution error: {}", e); + Ok(HookResult::failure(e.to_string(), start_time.elapsed())) + } + } + } + + fn executor_type(&self) -> &'static str { + "webhook" + } + + fn can_execute(&self, context: &HookContext) -> bool { + matches!(context.hook_type, HookType::Webhook { .. }) + } + + fn estimated_duration(&self) -> Option { + Some(Duration::from_secs(10)) // Network requests can be slower + } + + fn default_config(&self) -> ExecutionConfig { + ExecutionConfig { + timeout: Duration::from_secs(60), + mode: HookExecutionMode::Async, + priority: HookPriority::NORMAL, + required: false, + max_retries: 3, // Retry network failures + retry_delay: Duration::from_secs(1), + isolated: true, + } + } + + async fn prepare(&self, context: &HookContext) -> Result<(), HookError> { + // Validate webhook configuration + let _config = self.extract_webhook_config(context)?; + + // Additional validation could be added here (e.g., URL format validation) + Ok(()) + } + + async fn cleanup(&self, _context: &HookContext) -> Result<(), HookError> { + // No cleanup needed for webhook execution + Ok(()) + } +} + +/// Configuration for webhook execution. +#[derive(Debug, Clone)] +struct WebhookConfig { + /// URL to send the webhook to. + url: String, + /// HTTP method to use. + method: Method, + /// Headers to include in the request. + headers: HashMap, + /// Custom body to send (if None, uses generated payload). + body: Option, + /// Authentication configuration. + auth: Option, +} + +/// Authentication methods for webhooks. +#[derive(Debug, Clone)] +enum WebhookAuth { + /// Bearer token authentication. + Bearer { token: String }, + /// Basic authentication. + Basic { username: String, password: Option }, + /// Custom header authentication. + Header { name: String, value: String }, +} + +/// Result of webhook execution. +#[derive(Debug, Clone)] +struct WebhookResult { + /// HTTP status code. + status_code: u16, + /// Whether the request was successful. + success: bool, + /// Response body. + response_body: String, + /// Response headers. + response_headers: HashMap, + /// Duration of the request. + duration: Duration, + /// URL that was called. + url: String, + /// HTTP method used. + method: Method, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::types::LifecycleEvent; + use std::path::PathBuf; + + fn create_test_context(url: String) -> HookContext { + let event = LifecycleEvent::SessionStart { + session_id: "test-session".to_string(), + model: "test-model".to_string(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp")), + timestamp: chrono::Utc::now(), + }; + + let hook_type = HookType::Webhook { + url, + method: crate::hooks::types::HttpMethod::Post, + headers: HashMap::new(), + timeout: None, + retry_count: None, + }; + + HookContext::new(event, PathBuf::from("/tmp")) + .with_hook_type(hook_type) + } + + #[tokio::test] + async fn test_webhook_executor_creation() { + let executor = WebhookExecutor::new(); + assert_eq!(executor.executor_type(), "webhook"); + assert_eq!(executor.default_timeout, Duration::from_secs(30)); + assert_eq!(executor.max_response_size, 1024 * 1024); + } + + #[tokio::test] + async fn test_webhook_executor_with_timeout() { + let timeout = Duration::from_secs(120); + let executor = WebhookExecutor::with_timeout(timeout); + assert_eq!(executor.default_timeout, timeout); + } + + #[tokio::test] + async fn test_webhook_executor_can_execute() { + let executor = WebhookExecutor::new(); + let context = create_test_context("https://example.com/webhook".to_string()); + + assert!(executor.can_execute(&context)); + } + + #[tokio::test] + async fn test_webhook_payload_generation() { + let executor = WebhookExecutor::new(); + let context = create_test_context("https://example.com/webhook".to_string()); + + let payload = executor.build_payload(&context); + + assert!(payload["event"]["type"].is_string()); + assert!(payload["event"]["timestamp"].is_string()); + assert!(payload["hook"]["type"] == "webhook"); + assert!(payload["event"]["session_id"] == "test-session"); + assert!(payload["event"]["model"] == "test-model"); + } + + #[tokio::test] + async fn test_webhook_preparation() { + let executor = WebhookExecutor::new(); + let context = create_test_context("https://example.com/webhook".to_string()); + + let result = executor.prepare(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_webhook_cleanup() { + let executor = WebhookExecutor::new(); + let context = create_test_context("https://example.com/webhook".to_string()); + + let result = executor.cleanup(&context).await; + assert!(result.is_ok()); + } + + #[test] + fn test_webhook_executor_default_config() { + let executor = WebhookExecutor::new(); + let config = executor.default_config(); + + assert_eq!(config.timeout, Duration::from_secs(60)); + assert_eq!(config.mode, HookExecutionMode::Async); + assert_eq!(config.max_retries, 3); + assert!(config.isolated); + } + + #[tokio::test] + async fn test_invalid_webhook_url() { + let executor = WebhookExecutor::new(); + let context = create_test_context("".to_string()); // Empty URL + + let result = executor.extract_webhook_config(&context); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot be empty")); + } +} diff --git a/codex-rs/core/src/hooks/manager.rs b/codex-rs/core/src/hooks/manager.rs new file mode 100644 index 00000000000..4f0b9c2bcd9 --- /dev/null +++ b/codex-rs/core/src/hooks/manager.rs @@ -0,0 +1,555 @@ +//! Hook manager for coordinating hook execution. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures::future::join_all; +use tokio::time::timeout; + +use crate::hooks::config::HooksConfig; +use crate::hooks::context::{HookContext, HookExecutionContext}; +use crate::hooks::executor::{HookExecutor, ScriptExecutor, WebhookExecutor, McpToolExecutor, ExecutableExecutor}; +use crate::hooks::registry::HookRegistry; +use crate::hooks::types::{HookError, HookResult, HookType, LifecycleEvent, HookExecutionMode}; + +/// Execution metrics for testing and monitoring. +#[derive(Debug, Clone, Default)] +pub struct ExecutionMetrics { + pub total_executions: u64, + pub successful_executions: u64, + pub failed_executions: u64, + pub cancelled_executions: u64, + pub total_execution_time: Duration, + pub average_execution_time: Duration, +} + +/// Central manager for the lifecycle hooks system. +pub struct HookManager { + registry: Arc, + config: HooksConfig, + executors: HashMap>, + working_directory: PathBuf, + metrics: HookExecutionMetrics, +} + +/// Metrics for tracking hook execution performance. +#[derive(Debug, Clone, Default)] +pub struct HookExecutionMetrics { + pub total_executions: u64, + pub successful_executions: u64, + pub failed_executions: u64, + pub total_execution_time: Duration, + pub average_execution_time: Duration, +} + +/// Result of executing multiple hooks. +#[derive(Debug, Clone)] +pub struct HookExecutionResults { + pub successful: Vec, + pub failed: Vec, + pub total_duration: Duration, +} + +/// Result of executing a single hook. +#[derive(Debug, Clone)] +pub struct HookExecutionResult { + pub hook_description: String, + pub result: HookResult, + pub execution_time: Duration, +} + +impl HookManager { + /// Create a new hook manager with the given configuration. + pub async fn new(config: HooksConfig) -> Result { + Self::new_with_working_directory(config, std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"))).await + } + + /// Create a new hook manager with a specific working directory. + pub async fn new_with_working_directory(config: HooksConfig, working_directory: PathBuf) -> Result { + let registry = Arc::new(HookRegistry::new(config.clone()).await?); + + // Initialize hook executors + let mut executors: HashMap> = HashMap::new(); + executors.insert("script".to_string(), Box::new(ScriptExecutor::new())); + executors.insert("webhook".to_string(), Box::new(WebhookExecutor::new())); + executors.insert("mcp_tool".to_string(), Box::new(McpToolExecutor::new())); + executors.insert("executable".to_string(), Box::new(ExecutableExecutor)); + + Ok(Self { + registry, + config, + executors, + working_directory, + metrics: HookExecutionMetrics::default(), + }) + } + + /// Trigger a lifecycle event and execute all matching hooks. + pub async fn trigger_event(&self, event: LifecycleEvent) -> Result<(), HookError> { + if !self.config.hooks.enabled { + return Ok(()); + } + + let start_time = Instant::now(); + tracing::info!("Triggering lifecycle event: {:?}", event.event_type()); + + // Create hook execution context + let context = HookExecutionContext::new(event.clone(), self.working_directory.clone()) + .env("CODEX_HOOKS_ENABLED".to_string(), "true".to_string()) + .build(); + + // Get matching hooks from registry + let matching_hooks = self.registry.get_matching_hooks(&event, &context) + .map_err(|e| HookError::Execution(format!("Failed to get matching hooks: {}", e)))?; + + if matching_hooks.is_empty() { + tracing::debug!("No hooks found for event: {:?}", event.event_type()); + return Ok(()); + } + + tracing::info!("Found {} matching hooks for event: {:?}", matching_hooks.len(), event.event_type()); + + // Execute hooks based on their execution mode + let results = self.execute_hooks(matching_hooks, &context).await?; + + // Log execution results + let total_duration = start_time.elapsed(); + self.log_execution_results(&results, total_duration); + + // Handle any critical failures + self.handle_execution_results(&results)?; + + Ok(()) + } + + /// Check if hooks are enabled. + pub fn is_enabled(&self) -> bool { + self.config.hooks.enabled + } + + /// Get the hook registry. + pub fn registry(&self) -> Arc { + self.registry.clone() + } + + /// Get execution metrics. + pub fn metrics(&self) -> &HookExecutionMetrics { + &self.metrics + } + + /// Get execution metrics for testing and monitoring. + pub async fn get_execution_metrics(&self) -> ExecutionMetrics { + ExecutionMetrics { + total_executions: self.metrics.total_executions, + successful_executions: self.metrics.successful_executions, + failed_executions: self.metrics.failed_executions, + cancelled_executions: 0, // Not tracked in current metrics + total_execution_time: Duration::from_millis(self.metrics.total_execution_time_ms), + average_execution_time: Duration::from_millis(self.metrics.average_execution_time_ms), + } + } + + /// Reset execution metrics for testing. + pub async fn reset_metrics(&self) { + // Note: This is a simplified implementation for testing + // In a real implementation, we'd need mutable access to metrics + // For now, this is a no-op since metrics is not mutable + } + + /// Execute a list of hooks with the given context. + async fn execute_hooks( + &self, + hooks: Vec<&crate::hooks::config::HookConfig>, + context: &HookContext, + ) -> Result { + let mut successful = Vec::new(); + let mut failed = Vec::new(); + let start_time = Instant::now(); + + // Separate hooks by execution mode + let (blocking_hooks, async_hooks, fire_and_forget_hooks): (Vec<_>, Vec<_>, Vec<_>) = hooks + .into_iter() + .partition3(|hook| match hook.mode { + HookExecutionMode::Blocking => (true, false, false), + HookExecutionMode::Async => (false, true, false), + HookExecutionMode::FireAndForget => (false, false, true), + }); + + // Execute blocking hooks first (sequentially) + for hook in blocking_hooks { + let result = self.execute_single_hook(hook, context).await; + match result { + Ok(exec_result) => { + if exec_result.result.success { + successful.push(exec_result); + } else { + failed.push(exec_result.clone()); + // For blocking hooks, if required and failed, stop execution + if hook.required { + return Err(HookError::Execution(format!( + "Required blocking hook failed: {}", + exec_result.hook_description + ))); + } + } + } + Err(e) => { + let exec_result = HookExecutionResult { + hook_description: self.get_hook_description(hook), + result: HookResult::failure(e.to_string(), Duration::from_secs(0)), + execution_time: Duration::from_secs(0), + }; + failed.push(exec_result); + if hook.required { + return Err(e); + } + } + } + } + + // Execute async hooks in parallel + if !async_hooks.is_empty() { + let async_futures: Vec<_> = async_hooks + .into_iter() + .map(|hook| self.execute_single_hook(hook, context)) + .collect(); + + let async_results = join_all(async_futures).await; + for (i, result) in async_results.into_iter().enumerate() { + match result { + Ok(exec_result) => { + if exec_result.result.success { + successful.push(exec_result); + } else { + failed.push(exec_result); + } + } + Err(e) => { + let exec_result = HookExecutionResult { + hook_description: format!("Async hook {}", i), + result: HookResult::failure(e.to_string(), Duration::from_secs(0)), + execution_time: Duration::from_secs(0), + }; + failed.push(exec_result); + } + } + } + } + + // Execute fire-and-forget hooks (don't wait for results) + if !fire_and_forget_hooks.is_empty() { + for hook in fire_and_forget_hooks { + let hook_description = self.get_hook_description(hook); + let _context_clone = context.clone(); + + // Create a simple executor for fire-and-forget (we'll implement this properly later) + tokio::spawn(async move { + tracing::debug!("Fire-and-forget hook started: {}", hook_description); + // TODO: Implement actual execution in Phase 2.2 + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tracing::debug!("Fire-and-forget hook completed: {}", hook_description); + }); + } + } + + Ok(HookExecutionResults { + successful, + failed, + total_duration: start_time.elapsed(), + }) + } + + /// Execute a single hook with timeout and error handling. + async fn execute_single_hook( + &self, + hook: &crate::hooks::config::HookConfig, + context: &HookContext, + ) -> Result { + let start_time = Instant::now(); + let hook_description = self.get_hook_description(hook); + + tracing::debug!("Executing hook: {}", hook_description); + + // Get the appropriate executor + let executor = self.get_executor_for_hook(&hook.hook_type)?; + + // Get timeout (from hook config or global default) + let timeout_duration = hook.get_timeout(Duration::from_secs(self.config.hooks.timeout_seconds)); + + // Execute with timeout + let result = match timeout(timeout_duration, executor.execute(context)).await { + Ok(Ok(hook_result)) => { + tracing::debug!("Hook executed successfully: {}", hook_description); + hook_result + } + Ok(Err(e)) => { + tracing::warn!("Hook execution failed: {} - {}", hook_description, e); + HookResult::failure(e.to_string(), start_time.elapsed()) + } + Err(_) => { + let error_msg = format!("Hook execution timed out after {:?}", timeout_duration); + tracing::warn!("{}: {}", error_msg, hook_description); + HookResult::failure(error_msg, timeout_duration) + } + }; + + Ok(HookExecutionResult { + hook_description, + result, + execution_time: start_time.elapsed(), + }) + } + + /// Get the appropriate executor for a hook type. + fn get_executor_for_hook(&self, hook_type: &HookType) -> Result<&Box, HookError> { + let executor_key = match hook_type { + HookType::Script { .. } => "script", + HookType::Webhook { .. } => "webhook", + HookType::McpTool { .. } => "mcp_tool", + HookType::Executable { .. } => "executable", + }; + + self.executors.get(executor_key).ok_or_else(|| { + HookError::Execution(format!("No executor found for hook type: {}", executor_key)) + }) + } + + /// Get a human-readable description of a hook. + fn get_hook_description(&self, hook: &crate::hooks::config::HookConfig) -> String { + hook.description + .clone() + .unwrap_or_else(|| format!("{:?} hook", hook.hook_type)) + } + + /// Log the results of hook execution. + fn log_execution_results(&self, results: &HookExecutionResults, total_duration: Duration) { + let total_hooks = results.successful.len() + results.failed.len(); + + tracing::info!( + "Hook execution completed: {}/{} successful, {} failed, took {:?}", + results.successful.len(), + total_hooks, + results.failed.len(), + total_duration + ); + + // Log individual failures + for failed_result in &results.failed { + tracing::warn!( + "Hook failed: {} - {}", + failed_result.hook_description, + failed_result.result.error.as_ref().unwrap_or(&"Unknown error".to_string()) + ); + } + + // Log performance metrics + if !results.successful.is_empty() { + let avg_time: Duration = results.successful + .iter() + .map(|r| r.execution_time) + .sum::() / results.successful.len() as u32; + + tracing::debug!("Average successful hook execution time: {:?}", avg_time); + } + } + + /// Handle execution results and determine if any critical failures occurred. + fn handle_execution_results(&self, results: &HookExecutionResults) -> Result<(), HookError> { + // Check if there were any critical failures that should stop execution + let critical_failures: Vec<_> = results.failed + .iter() + .filter(|result| result.hook_description.contains("required")) + .collect(); + + if !critical_failures.is_empty() { + let error_msg = format!( + "Critical hook failures detected: {}", + critical_failures + .iter() + .map(|r| r.hook_description.as_str()) + .collect::>() + .join(", ") + ); + return Err(HookError::Execution(error_msg)); + } + + Ok(()) + } +} + +/// Helper trait for partitioning iterators into three groups. +trait Partition3 { + fn partition3(self, predicate: F) -> (Vec, Vec, Vec) + where + F: Fn(&T) -> (bool, bool, bool); +} + +impl Partition3 for I +where + I: Iterator, +{ + fn partition3(self, predicate: F) -> (Vec, Vec, Vec) + where + F: Fn(&T) -> (bool, bool, bool), + { + let mut first = Vec::new(); + let mut second = Vec::new(); + let mut third = Vec::new(); + + for item in self { + let (is_first, is_second, is_third) = predicate(&item); + if is_first { + first.push(item); + } else if is_second { + second.push(item); + } else if is_third { + third.push(item); + } + } + + (first, second, third) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_config() -> HooksConfig { + HooksConfig { + hooks: crate::hooks::config::GlobalHooksConfig { + enabled: true, + timeout_seconds: 30, + parallel_execution: true, + session: Vec::new(), + task: Vec::new(), + exec: Vec::new(), + patch: Vec::new(), + mcp: Vec::new(), + agent: Vec::new(), + error: Vec::new(), + integration: Vec::new(), + }, + } + } + + #[tokio::test] + async fn test_hook_manager_creation() { + let config = create_test_config(); + let manager = HookManager::new(config).await.unwrap(); + + assert!(manager.is_enabled()); + assert_eq!(manager.executors.len(), 4); // script, webhook, mcp_tool, executable + } + + #[tokio::test] + async fn test_hook_manager_disabled() { + let mut config = create_test_config(); + config.hooks.enabled = false; + + let manager = HookManager::new(config).await.unwrap(); + assert!(!manager.is_enabled()); + + // Test that disabled manager doesn't execute hooks + let event = LifecycleEvent::TaskStart { + task_id: "test-task".to_string(), + session_id: "test-session".to_string(), + prompt: "Test task".to_string(), + timestamp: chrono::Utc::now(), + }; + + let result = manager.trigger_event(event).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_hook_manager_with_working_directory() { + let temp_dir = TempDir::new().unwrap(); + let config = create_test_config(); + + let manager = HookManager::new_with_working_directory( + config, + temp_dir.path().to_path_buf(), + ).await.unwrap(); + + assert_eq!(manager.working_directory, temp_dir.path()); + } + + #[tokio::test] + async fn test_hook_execution_metrics() { + let config = create_test_config(); + let manager = HookManager::new(config).await.unwrap(); + + let metrics = manager.metrics(); + assert_eq!(metrics.total_executions, 0); + assert_eq!(metrics.successful_executions, 0); + assert_eq!(metrics.failed_executions, 0); + } + + #[tokio::test] + async fn test_trigger_event_no_matching_hooks() { + let config = create_test_config(); + let manager = HookManager::new(config).await.unwrap(); + + let event = LifecycleEvent::SessionStart { + session_id: "test-session".to_string(), + model: "test-model".to_string(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp")), + timestamp: chrono::Utc::now(), + }; + + // Should succeed even with no matching hooks + let result = manager.trigger_event(event).await; + assert!(result.is_ok()); + } + + #[test] + fn test_partition3_helper() { + let items = vec![1, 2, 3, 4, 5, 6]; + let (first, second, third) = items.into_iter().partition3(|&x| { + if x % 3 == 0 { + (true, false, false) // divisible by 3 + } else if x % 2 == 0 { + (false, true, false) // even + } else { + (false, false, true) // odd + } + }); + + assert_eq!(first, vec![3, 6]); // divisible by 3 + assert_eq!(second, vec![2, 4]); // even (but not divisible by 3) + assert_eq!(third, vec![1, 5]); // odd + } + + #[test] + fn test_hook_execution_results() { + let successful = vec![ + HookExecutionResult { + hook_description: "test hook 1".to_string(), + result: HookResult::success(Some("output".to_string()), Duration::from_millis(100)), + execution_time: Duration::from_millis(100), + }, + ]; + + let failed = vec![ + HookExecutionResult { + hook_description: "test hook 2".to_string(), + result: HookResult::failure("error".to_string(), Duration::from_millis(50)), + execution_time: Duration::from_millis(50), + }, + ]; + + let results = HookExecutionResults { + successful, + failed, + total_duration: Duration::from_millis(150), + }; + + assert_eq!(results.successful.len(), 1); + assert_eq!(results.failed.len(), 1); + assert_eq!(results.total_duration, Duration::from_millis(150)); + } +} diff --git a/codex-rs/core/src/hooks/mod.rs b/codex-rs/core/src/hooks/mod.rs new file mode 100644 index 00000000000..15cfb755a6b --- /dev/null +++ b/codex-rs/core/src/hooks/mod.rs @@ -0,0 +1,91 @@ +//! Lifecycle hooks system for Codex. +//! +//! This module provides a comprehensive lifecycle hooks system that allows external +//! scripts, webhooks, and integrations to be triggered at specific points in the +//! Codex execution lifecycle. +//! +//! # Architecture +//! +//! The hooks system is built around the following core components: +//! +//! - **Hook Manager**: Central registry and coordinator for all lifecycle hooks +//! - **Hook Registry**: Stores hook definitions and manages event routing +//! - **Hook Executors**: Execute different types of hooks (scripts, webhooks, MCP tools) +//! - **Hook Context**: Provides execution context and data to hooks +//! +//! # Hook Types +//! +//! The system supports several types of hooks: +//! +//! - **Script Hooks**: Execute shell scripts or commands with event context +//! - **Webhook Hooks**: Send HTTP requests to external APIs with event data +//! - **MCP Tool Hooks**: Call MCP tools as lifecycle hooks +//! - **Custom Executable Hooks**: Execute any binary with event data +//! +//! # Lifecycle Events +//! +//! Hooks can be triggered by various lifecycle events: +//! +//! - Session lifecycle (start, end) +//! - Task lifecycle (start, complete) +//! - Execution lifecycle (before/after command execution) +//! - Patch lifecycle (before/after patch application) +//! - MCP tool lifecycle (before/after tool calls) +//! - Agent interactions (messages, reasoning) +//! - Error handling +//! +//! # Usage +//! +//! ```rust +//! use codex_core::hooks::{HookManager, LifecycleEvent}; +//! +//! // Initialize the hook manager with configuration +//! let hook_manager = HookManager::new(config.hooks).await?; +//! +//! // Trigger a lifecycle event +//! let event = LifecycleEvent::TaskStart { +//! task_id: "task_123".to_string(), +//! prompt: "Create a new file".to_string(), +//! }; +//! hook_manager.trigger_event(event).await?; +//! ``` +//! +//! # Configuration +//! +//! Hooks are configured via the `hooks.toml` configuration file: +//! +//! ```toml +//! [hooks] +//! enabled = true +//! timeout_seconds = 30 +//! +//! [[hooks.task]] +//! event = "task.start" +//! type = "script" +//! command = ["./scripts/log-task-start.sh"] +//! ``` + +pub mod config; +pub mod context; +pub mod executor; +pub mod executors; +pub mod manager; +pub mod protocol_integration; +pub mod registry; +pub mod types; + +#[cfg(test)] +mod tests; + +// Re-export commonly used types +pub use config::{HookConfig, HooksConfig}; +pub use context::{HookContext, HookExecutionContext}; +pub use executor::{HookExecutor, HookExecutorResult}; +pub use manager::HookManager; +pub use registry::{HookRegistry, HookRegistryStatistics}; +pub use types::{ + HookError, HookResult, HookType, LifecycleEvent, LifecycleEventType, HookExecutionMode, +}; + +/// Result type for hook operations +pub type Result = std::result::Result; diff --git a/codex-rs/core/src/hooks/protocol_integration.rs b/codex-rs/core/src/hooks/protocol_integration.rs new file mode 100644 index 00000000000..861fa7373cc --- /dev/null +++ b/codex-rs/core/src/hooks/protocol_integration.rs @@ -0,0 +1,306 @@ +//! Integration between hooks system and protocol events. + +use std::time::Duration; + +use uuid::Uuid; + +use crate::hooks::types::{HookExecutionMode, HookPriority, HookResult, LifecycleEvent}; +use crate::protocol::{ + Event, EventMsg, HookExecutionBeginEvent, HookExecutionEndEvent, SessionEndEvent, + SessionStartEvent, +}; + +/// Converts hook lifecycle events to protocol events for client communication. +pub struct ProtocolEventConverter; + +impl ProtocolEventConverter { + /// Convert a lifecycle event to a protocol event (if applicable). + pub fn convert_lifecycle_event(event: &LifecycleEvent) -> Option { + match event { + LifecycleEvent::SessionStart { + session_id, + model, + cwd, + timestamp, + } => Some(Event { + id: Uuid::new_v4().to_string(), + msg: EventMsg::SessionStart(SessionStartEvent { + session_id: session_id.clone(), + model: model.clone(), + cwd: cwd.clone(), + timestamp: timestamp.to_rfc3339(), + }), + }), + LifecycleEvent::SessionEnd { + session_id, + duration, + timestamp, + } => Some(Event { + id: Uuid::new_v4().to_string(), + msg: EventMsg::SessionEnd(SessionEndEvent { + session_id: session_id.clone(), + duration_ms: duration.as_millis() as u64, + timestamp: timestamp.to_rfc3339(), + }), + }), + // Other lifecycle events don't need to be converted to protocol events + // as they are internal to the hook system + _ => None, + } + } + + /// Create a hook execution begin event for protocol communication. + pub fn create_hook_execution_begin_event( + execution_id: String, + event_type: &LifecycleEvent, + hook_type: &str, + hook_description: Option, + execution_mode: HookExecutionMode, + priority: HookPriority, + required: bool, + ) -> Event { + Event { + id: Uuid::new_v4().to_string(), + msg: EventMsg::HookExecutionBegin(HookExecutionBeginEvent { + execution_id, + event_type: event_type.event_type().to_string(), + hook_type: hook_type.to_string(), + hook_description, + execution_mode: match execution_mode { + HookExecutionMode::Async => "async".to_string(), + HookExecutionMode::Blocking => "blocking".to_string(), + HookExecutionMode::FireAndForget => "fire_and_forget".to_string(), + }, + priority: priority.value(), + required, + timestamp: chrono::Utc::now().to_rfc3339(), + }), + } + } + + /// Create a hook execution end event for protocol communication. + pub fn create_hook_execution_end_event( + execution_id: String, + result: &HookResult, + retry_attempts: u32, + cancelled: bool, + ) -> Event { + Event { + id: Uuid::new_v4().to_string(), + msg: EventMsg::HookExecutionEnd(HookExecutionEndEvent { + execution_id, + success: result.success, + output: result.output.clone(), + error: result.error.clone(), + duration_ms: result.duration.as_millis() as u64, + retry_attempts, + cancelled, + timestamp: chrono::Utc::now().to_rfc3339(), + }), + } + } + + /// Create a session start event from session information. + pub fn create_session_start_event( + session_id: String, + model: String, + cwd: std::path::PathBuf, + ) -> Event { + Event { + id: Uuid::new_v4().to_string(), + msg: EventMsg::SessionStart(SessionStartEvent { + session_id, + model, + cwd, + timestamp: chrono::Utc::now().to_rfc3339(), + }), + } + } + + /// Create a session end event from session information. + pub fn create_session_end_event(session_id: String, duration: Duration) -> Event { + Event { + id: Uuid::new_v4().to_string(), + msg: EventMsg::SessionEnd(SessionEndEvent { + session_id, + duration_ms: duration.as_millis() as u64, + timestamp: chrono::Utc::now().to_rfc3339(), + }), + } + } +} + +/// Trait for components that can emit protocol events. +pub trait ProtocolEventEmitter { + /// Emit a protocol event to the client. + fn emit_event(&self, event: Event); +} + +/// Mock implementation for testing. +#[derive(Debug, Default)] +pub struct MockProtocolEventEmitter { + pub events: std::sync::Mutex>, +} + +impl MockProtocolEventEmitter { + pub fn new() -> Self { + Self::default() + } + + pub fn get_events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + pub fn clear_events(&self) { + self.events.lock().unwrap().clear(); + } +} + +impl ProtocolEventEmitter for MockProtocolEventEmitter { + fn emit_event(&self, event: Event) { + self.events.lock().unwrap().push(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + + #[test] + fn test_convert_session_start_event() { + let lifecycle_event = LifecycleEvent::SessionStart { + session_id: "test-session".to_string(), + model: "test-model".to_string(), + cwd: PathBuf::from("/test"), + timestamp: chrono::Utc::now(), + }; + + let protocol_event = ProtocolEventConverter::convert_lifecycle_event(&lifecycle_event); + assert!(protocol_event.is_some()); + + let event = protocol_event.unwrap(); + match event.msg { + EventMsg::SessionStart(session_start) => { + assert_eq!(session_start.session_id, "test-session"); + assert_eq!(session_start.model, "test-model"); + assert_eq!(session_start.cwd, PathBuf::from("/test")); + } + _ => panic!("Expected SessionStart event"), + } + } + + #[test] + fn test_convert_session_end_event() { + let lifecycle_event = LifecycleEvent::SessionEnd { + session_id: "test-session".to_string(), + duration: Duration::from_secs(60), + timestamp: chrono::Utc::now(), + }; + + let protocol_event = ProtocolEventConverter::convert_lifecycle_event(&lifecycle_event); + assert!(protocol_event.is_some()); + + let event = protocol_event.unwrap(); + match event.msg { + EventMsg::SessionEnd(session_end) => { + assert_eq!(session_end.session_id, "test-session"); + assert_eq!(session_end.duration_ms, 60000); + } + _ => panic!("Expected SessionEnd event"), + } + } + + #[test] + fn test_convert_non_session_event_returns_none() { + let lifecycle_event = LifecycleEvent::TaskStart { + task_id: "test-task".to_string(), + session_id: "test-session".to_string(), + prompt: "test prompt".to_string(), + timestamp: chrono::Utc::now(), + }; + + let protocol_event = ProtocolEventConverter::convert_lifecycle_event(&lifecycle_event); + assert!(protocol_event.is_none()); + } + + #[test] + fn test_create_hook_execution_begin_event() { + let lifecycle_event = LifecycleEvent::TaskStart { + task_id: "test-task".to_string(), + session_id: "test-session".to_string(), + prompt: "test prompt".to_string(), + timestamp: chrono::Utc::now(), + }; + + let event = ProtocolEventConverter::create_hook_execution_begin_event( + "exec-123".to_string(), + &lifecycle_event, + "script", + Some("Test hook".to_string()), + HookExecutionMode::Async, + HookPriority::NORMAL, + true, + ); + + match event.msg { + EventMsg::HookExecutionBegin(begin_event) => { + assert_eq!(begin_event.execution_id, "exec-123"); + assert_eq!(begin_event.event_type, "task_start"); + assert_eq!(begin_event.hook_type, "script"); + assert_eq!(begin_event.hook_description, Some("Test hook".to_string())); + assert_eq!(begin_event.execution_mode, "async"); + assert_eq!(begin_event.priority, HookPriority::NORMAL.value()); + assert!(begin_event.required); + } + _ => panic!("Expected HookExecutionBegin event"), + } + } + + #[test] + fn test_create_hook_execution_end_event() { + let result = HookResult::success(Some("Hook completed".to_string()), Duration::from_millis(500)); + + let event = ProtocolEventConverter::create_hook_execution_end_event( + "exec-123".to_string(), + &result, + 2, + false, + ); + + match event.msg { + EventMsg::HookExecutionEnd(end_event) => { + assert_eq!(end_event.execution_id, "exec-123"); + assert!(end_event.success); + assert_eq!(end_event.output, Some("Hook completed".to_string())); + assert_eq!(end_event.error, None); + assert_eq!(end_event.duration_ms, 500); + assert_eq!(end_event.retry_attempts, 2); + assert!(!end_event.cancelled); + } + _ => panic!("Expected HookExecutionEnd event"), + } + } + + #[test] + fn test_mock_protocol_event_emitter() { + let emitter = MockProtocolEventEmitter::new(); + + let event = ProtocolEventConverter::create_session_start_event( + "test-session".to_string(), + "test-model".to_string(), + PathBuf::from("/test"), + ); + + emitter.emit_event(event.clone()); + + let events = emitter.get_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, event.id); + + emitter.clear_events(); + let events = emitter.get_events(); + assert_eq!(events.len(), 0); + } +} diff --git a/codex-rs/core/src/hooks/registry.rs b/codex-rs/core/src/hooks/registry.rs new file mode 100644 index 00000000000..02cf9eddf54 --- /dev/null +++ b/codex-rs/core/src/hooks/registry.rs @@ -0,0 +1,465 @@ +//! Hook registry for managing hook definitions and routing. + +use std::collections::HashMap; + +use crate::hooks::config::{HooksConfig, HookConfig}; +use crate::hooks::context::HookContext; +use crate::hooks::types::{HookError, LifecycleEvent, LifecycleEventType, HookPriority}; + +/// Registry for managing hook definitions and event routing. +pub struct HookRegistry { + hooks_by_event: HashMap>, + config: HooksConfig, +} + +impl HookRegistry { + /// Create a new hook registry with the given configuration. + pub async fn new(config: HooksConfig) -> Result { + let mut registry = Self { + hooks_by_event: HashMap::new(), + config: config.clone(), + }; + + // Populate hooks from configuration + registry.load_hooks_from_config(&config)?; + + Ok(registry) + } + + /// Load hooks from the configuration into the registry. + fn load_hooks_from_config(&mut self, config: &HooksConfig) -> Result<(), HookError> { + // Load hooks from all categories + let all_hook_groups = [ + (&config.hooks.session, "session"), + (&config.hooks.task, "task"), + (&config.hooks.exec, "exec"), + (&config.hooks.patch, "patch"), + (&config.hooks.mcp, "mcp"), + (&config.hooks.agent, "agent"), + (&config.hooks.error, "error"), + (&config.hooks.integration, "integration"), + ]; + + for (hook_group, category) in all_hook_groups { + for hook in hook_group { + self.register_hook_internal(hook.clone(), category)?; + } + } + + // Sort hooks by priority within each event type + self.sort_hooks_by_priority(); + + Ok(()) + } + + /// Internal method to register a hook with category tracking. + fn register_hook_internal(&mut self, hook: HookConfig, category: &str) -> Result<(), HookError> { + // Validate the hook configuration + hook.validate().map_err(|e| { + HookError::Registry(format!("Invalid hook in {} category: {}", category, e)) + })?; + + // Add the hook to the appropriate event type + self.hooks_by_event + .entry(hook.event) + .or_insert_with(Vec::new) + .push(hook); + + Ok(()) + } + + /// Sort hooks by priority within each event type. + fn sort_hooks_by_priority(&mut self) { + for hooks in self.hooks_by_event.values_mut() { + hooks.sort_by_key(|hook| hook.priority); + } + } + + /// Get all hooks for a specific event type, sorted by priority. + pub fn get_hooks_for_event(&self, event_type: LifecycleEventType) -> Vec<&HookConfig> { + self.hooks_by_event + .get(&event_type) + .map(|hooks| hooks.iter().collect()) + .unwrap_or_default() + } + + /// Get hooks for a specific event type that match the given condition. + pub fn get_matching_hooks( + &self, + event: &LifecycleEvent, + context: &HookContext, + ) -> Result, HookError> { + let event_type = event.event_type(); + let all_hooks = self.get_hooks_for_event(event_type); + + let mut matching_hooks = Vec::new(); + + for hook in all_hooks { + if self.evaluate_hook_condition(hook, event, context)? { + matching_hooks.push(hook); + } + } + + Ok(matching_hooks) + } + + /// Evaluate whether a hook's condition is met for the given event and context. + fn evaluate_hook_condition( + &self, + hook: &HookConfig, + event: &LifecycleEvent, + context: &HookContext, + ) -> Result { + // If no condition is specified, the hook always matches + let Some(condition) = &hook.condition else { + return Ok(true); + }; + + // Evaluate the condition + self.evaluate_condition_expression(condition, event, context) + } + + /// Evaluate a condition expression. + /// This is a basic implementation - in the future this could be extended + /// with a proper expression parser. + fn evaluate_condition_expression( + &self, + condition: &str, + event: &LifecycleEvent, + context: &HookContext, + ) -> Result { + // Basic condition evaluation + // For now, we support simple conditions like: + // - "success == true" + // - "exit_code == 0" + // - "message.contains('ERROR')" + // - "task_id == 'specific_task'" + + let condition = condition.trim(); + + // Handle boolean conditions + if condition == "true" { + return Ok(true); + } + if condition == "false" { + return Ok(false); + } + + // Handle equality conditions + if let Some((left, right)) = condition.split_once("==") { + let left = left.trim(); + let right = right.trim().trim_matches('"').trim_matches('\''); + + return self.evaluate_equality_condition(left, right, event, context); + } + + // Handle contains conditions + if condition.contains(".contains(") { + return self.evaluate_contains_condition(condition, event, context); + } + + // Handle not equals conditions + if let Some((left, right)) = condition.split_once("!=") { + let left = left.trim(); + let right = right.trim().trim_matches('"').trim_matches('\''); + + return Ok(!self.evaluate_equality_condition(left, right, event, context)?); + } + + // If we can't parse the condition, log a warning and return true + tracing::warn!("Unknown condition format: '{}', defaulting to true", condition); + Ok(true) + } + + /// Evaluate an equality condition. + fn evaluate_equality_condition( + &self, + left: &str, + right: &str, + event: &LifecycleEvent, + context: &HookContext, + ) -> Result { + let left_value = self.get_condition_value(left, event, context)?; + Ok(left_value == right) + } + + /// Evaluate a contains condition. + fn evaluate_contains_condition( + &self, + condition: &str, + event: &LifecycleEvent, + context: &HookContext, + ) -> Result { + // Parse "field.contains('value')" format + if let Some(start) = condition.find(".contains(") { + let field = condition[..start].trim(); + let rest = &condition[start + 10..]; // Skip ".contains(" + + if let Some(end) = rest.find(')') { + let value = rest[..end].trim().trim_matches('"').trim_matches('\''); + let field_value = self.get_condition_value(field, event, context)?; + return Ok(field_value.contains(value)); + } + } + + Err(HookError::Registry(format!( + "Invalid contains condition format: '{}'", + condition + ))) + } + + /// Get the value of a field for condition evaluation. + fn get_condition_value( + &self, + field: &str, + event: &LifecycleEvent, + context: &HookContext, + ) -> Result { + match field { + "success" => match event { + LifecycleEvent::TaskComplete { success, .. } + | LifecycleEvent::PatchAfter { success, .. } + | LifecycleEvent::McpToolAfter { success, .. } => Ok(success.to_string()), + LifecycleEvent::ExecAfter { exit_code, .. } => Ok((exit_code == &0).to_string()), + _ => Ok("false".to_string()), + }, + "exit_code" => match event { + LifecycleEvent::ExecAfter { exit_code, .. } => Ok(exit_code.to_string()), + _ => Ok("0".to_string()), + }, + "message" => match event { + LifecycleEvent::AgentMessage { message, .. } => Ok(message.clone()), + _ => Ok(String::new()), + }, + "task_id" => Ok(event.task_id().unwrap_or("").to_string()), + "session_id" => match event { + LifecycleEvent::SessionStart { session_id, .. } + | LifecycleEvent::SessionEnd { session_id, .. } + | LifecycleEvent::TaskStart { session_id, .. } + | LifecycleEvent::TaskComplete { session_id, .. } => Ok(session_id.clone()), + _ => Ok(String::new()), + }, + "model" => match event { + LifecycleEvent::SessionStart { model, .. } => Ok(model.clone()), + _ => Ok(String::new()), + }, + "server" => match event { + LifecycleEvent::McpToolBefore { server, .. } + | LifecycleEvent::McpToolAfter { server, .. } => Ok(server.clone()), + _ => Ok(String::new()), + }, + "tool" => match event { + LifecycleEvent::McpToolBefore { tool, .. } + | LifecycleEvent::McpToolAfter { tool, .. } => Ok(tool.clone()), + _ => Ok(String::new()), + }, + // Check environment variables + field if field.starts_with("env.") => { + let env_var = &field[4..]; // Remove "env." prefix + Ok(context.get_env(env_var).cloned().unwrap_or_default()) + }, + _ => { + tracing::warn!("Unknown condition field: '{}'", field); + Ok(String::new()) + } + } + } + + /// Register a new hook at runtime. + pub fn register_hook(&mut self, hook: HookConfig) -> Result<(), HookError> { + self.register_hook_internal(hook, "runtime")?; + self.sort_hooks_by_priority(); + Ok(()) + } + + /// Remove hooks matching a predicate. + pub fn remove_hooks(&mut self, predicate: F) -> usize + where + F: Fn(&HookConfig) -> bool, + { + let mut removed_count = 0; + + for hooks in self.hooks_by_event.values_mut() { + let original_len = hooks.len(); + hooks.retain(|hook| !predicate(hook)); + removed_count += original_len - hooks.len(); + } + + removed_count + } + + /// Get hooks by tag. + pub fn get_hooks_by_tag(&self, tag: &str) -> Vec<&HookConfig> { + let mut matching_hooks = Vec::new(); + + for hooks in self.hooks_by_event.values() { + for hook in hooks { + if hook.tags.contains(&tag.to_string()) { + matching_hooks.push(hook); + } + } + } + + // Sort by priority + matching_hooks.sort_by_key(|hook| hook.priority); + matching_hooks + } + + /// Get hooks by priority range. + pub fn get_hooks_by_priority_range( + &self, + min_priority: HookPriority, + max_priority: HookPriority, + ) -> Vec<&HookConfig> { + let mut matching_hooks = Vec::new(); + + for hooks in self.hooks_by_event.values() { + for hook in hooks { + if hook.priority >= min_priority && hook.priority <= max_priority { + matching_hooks.push(hook); + } + } + } + + // Sort by priority + matching_hooks.sort_by_key(|hook| hook.priority); + matching_hooks + } + + /// Get statistics about registered hooks. + pub fn get_statistics(&self) -> HookRegistryStatistics { + let mut total_hooks = 0; + let mut hooks_by_event_count = HashMap::new(); + let mut hooks_by_priority = HashMap::new(); + + for (event_type, hooks) in &self.hooks_by_event { + let count = hooks.len(); + total_hooks += count; + hooks_by_event_count.insert(*event_type, count); + + for hook in hooks { + *hooks_by_priority.entry(hook.priority).or_insert(0) += 1; + } + } + + HookRegistryStatistics { + total_hooks, + hooks_by_event: hooks_by_event_count, + hooks_by_priority, + enabled: self.config.hooks.enabled, + } + } + + /// Check if the registry is enabled. + pub fn is_enabled(&self) -> bool { + self.config.hooks.enabled + } +} + +/// Statistics about the hook registry. +#[derive(Debug, Clone)] +pub struct HookRegistryStatistics { + pub total_hooks: usize, + pub hooks_by_event: HashMap, + pub hooks_by_priority: HashMap, + pub enabled: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::config::{GlobalHooksConfig, HooksConfig}; + use crate::hooks::types::{HookType, HookExecutionMode}; + use std::collections::HashMap; + use std::path::PathBuf; + + fn create_test_hook(event: LifecycleEventType, priority: HookPriority) -> HookConfig { + HookConfig { + event, + hook_type: HookType::Script { + command: vec!["echo".to_string(), "test".to_string()], + cwd: None, + environment: HashMap::new(), + timeout: None, + }, + mode: HookExecutionMode::Async, + priority, + condition: None, + blocking: false, + required: false, + tags: Vec::new(), + description: None, + } + } + + #[tokio::test] + async fn test_hook_registry_creation() { + let config = HooksConfig { + hooks: GlobalHooksConfig { + enabled: true, + timeout_seconds: 30, + parallel_execution: true, + task: vec![create_test_hook(LifecycleEventType::TaskStart, HookPriority::NORMAL)], + ..Default::default() + }, + }; + + let registry = HookRegistry::new(config).await.unwrap(); + let hooks = registry.get_hooks_for_event(LifecycleEventType::TaskStart); + assert_eq!(hooks.len(), 1); + } + + #[tokio::test] + async fn test_hook_priority_sorting() { + let config = HooksConfig { + hooks: GlobalHooksConfig { + enabled: true, + timeout_seconds: 30, + parallel_execution: true, + task: vec![ + create_test_hook(LifecycleEventType::TaskStart, HookPriority::LOW), + create_test_hook(LifecycleEventType::TaskStart, HookPriority::HIGH), + create_test_hook(LifecycleEventType::TaskStart, HookPriority::NORMAL), + ], + ..Default::default() + }, + }; + + let registry = HookRegistry::new(config).await.unwrap(); + let hooks = registry.get_hooks_for_event(LifecycleEventType::TaskStart); + + // Should be sorted by priority (HIGH < NORMAL < LOW) + assert_eq!(hooks[0].priority, HookPriority::HIGH); + assert_eq!(hooks[1].priority, HookPriority::NORMAL); + assert_eq!(hooks[2].priority, HookPriority::LOW); + } + + #[tokio::test] + async fn test_condition_evaluation() { + let config = HooksConfig::default(); + let registry = HookRegistry::new(config).await.unwrap(); + + let event = LifecycleEvent::TaskComplete { + task_id: "test_task".to_string(), + session_id: "test_session".to_string(), + success: true, + output: None, + duration: std::time::Duration::from_secs(1), + timestamp: chrono::Utc::now(), + }; + + let context = HookContext::new(event.clone(), PathBuf::from("/tmp")); + + // Test simple boolean condition + assert!(registry.evaluate_condition_expression("true", &event, &context).unwrap()); + assert!(!registry.evaluate_condition_expression("false", &event, &context).unwrap()); + + // Test equality condition + assert!(registry.evaluate_condition_expression("success == true", &event, &context).unwrap()); + assert!(!registry.evaluate_condition_expression("success == false", &event, &context).unwrap()); + + // Test task_id condition + assert!(registry.evaluate_condition_expression("task_id == test_task", &event, &context).unwrap()); + assert!(!registry.evaluate_condition_expression("task_id == other_task", &event, &context).unwrap()); + } +} diff --git a/codex-rs/core/src/hooks/types.rs b/codex-rs/core/src/hooks/types.rs new file mode 100644 index 00000000000..fcd4dea15b2 --- /dev/null +++ b/codex-rs/core/src/hooks/types.rs @@ -0,0 +1,410 @@ +//! Core type definitions for the lifecycle hooks system. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Lifecycle events that can trigger hooks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LifecycleEvent { + /// Session lifecycle events + SessionStart { + session_id: String, + model: String, + cwd: PathBuf, + timestamp: chrono::DateTime, + }, + SessionEnd { + session_id: String, + duration: Duration, + timestamp: chrono::DateTime, + }, + + /// Task lifecycle events + TaskStart { + task_id: String, + session_id: String, + prompt: String, + timestamp: chrono::DateTime, + }, + TaskComplete { + task_id: String, + session_id: String, + success: bool, + output: Option, + duration: Duration, + timestamp: chrono::DateTime, + }, + + /// Execution lifecycle events + ExecBefore { + call_id: String, + task_id: String, + command: Vec, + cwd: PathBuf, + timestamp: chrono::DateTime, + }, + ExecAfter { + call_id: String, + task_id: String, + command: Vec, + exit_code: i32, + stdout: String, + stderr: String, + duration: Duration, + timestamp: chrono::DateTime, + }, + + /// Patch lifecycle events + PatchBefore { + call_id: String, + task_id: String, + changes: HashMap, // file path -> change description + timestamp: chrono::DateTime, + }, + PatchAfter { + call_id: String, + task_id: String, + success: bool, + applied_files: Vec, + duration: Duration, + timestamp: chrono::DateTime, + }, + + /// MCP tool lifecycle events + McpToolBefore { + call_id: String, + task_id: String, + server: String, + tool: String, + arguments: Option, + timestamp: chrono::DateTime, + }, + McpToolAfter { + call_id: String, + task_id: String, + server: String, + tool: String, + success: bool, + result: Option, + duration: Duration, + timestamp: chrono::DateTime, + }, + + /// Agent interaction events + AgentMessage { + task_id: String, + message: String, + reasoning: Option, + timestamp: chrono::DateTime, + }, + + /// Error events + ErrorOccurred { + task_id: Option, + error: String, + context: ErrorContext, + timestamp: chrono::DateTime, + }, +} + +/// Context information for error events. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ErrorContext { + pub component: String, + pub operation: Option, + pub details: HashMap, +} + +/// Enumeration of lifecycle event types for filtering and matching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecycleEventType { + SessionStart, + SessionEnd, + TaskStart, + TaskComplete, + ExecBefore, + ExecAfter, + PatchBefore, + PatchAfter, + McpToolBefore, + McpToolAfter, + AgentMessage, + ErrorOccurred, +} + +impl std::fmt::Display for LifecycleEventType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LifecycleEventType::SessionStart => write!(f, "session_start"), + LifecycleEventType::SessionEnd => write!(f, "session_end"), + LifecycleEventType::TaskStart => write!(f, "task_start"), + LifecycleEventType::TaskComplete => write!(f, "task_complete"), + LifecycleEventType::ExecBefore => write!(f, "exec_before"), + LifecycleEventType::ExecAfter => write!(f, "exec_after"), + LifecycleEventType::PatchBefore => write!(f, "patch_before"), + LifecycleEventType::PatchAfter => write!(f, "patch_after"), + LifecycleEventType::McpToolBefore => write!(f, "mcp_tool_before"), + LifecycleEventType::McpToolAfter => write!(f, "mcp_tool_after"), + LifecycleEventType::AgentMessage => write!(f, "agent_message"), + LifecycleEventType::ErrorOccurred => write!(f, "error_occurred"), + } + } +} + +impl LifecycleEvent { + /// Get the event type for this lifecycle event. + pub fn event_type(&self) -> LifecycleEventType { + match self { + LifecycleEvent::SessionStart { .. } => LifecycleEventType::SessionStart, + LifecycleEvent::SessionEnd { .. } => LifecycleEventType::SessionEnd, + LifecycleEvent::TaskStart { .. } => LifecycleEventType::TaskStart, + LifecycleEvent::TaskComplete { .. } => LifecycleEventType::TaskComplete, + LifecycleEvent::ExecBefore { .. } => LifecycleEventType::ExecBefore, + LifecycleEvent::ExecAfter { .. } => LifecycleEventType::ExecAfter, + LifecycleEvent::PatchBefore { .. } => LifecycleEventType::PatchBefore, + LifecycleEvent::PatchAfter { .. } => LifecycleEventType::PatchAfter, + LifecycleEvent::McpToolBefore { .. } => LifecycleEventType::McpToolBefore, + LifecycleEvent::McpToolAfter { .. } => LifecycleEventType::McpToolAfter, + LifecycleEvent::AgentMessage { .. } => LifecycleEventType::AgentMessage, + LifecycleEvent::ErrorOccurred { .. } => LifecycleEventType::ErrorOccurred, + } + } + + /// Get the task ID associated with this event, if any. + pub fn task_id(&self) -> Option<&str> { + match self { + LifecycleEvent::SessionStart { .. } | LifecycleEvent::SessionEnd { .. } => None, + LifecycleEvent::TaskStart { task_id, .. } + | LifecycleEvent::TaskComplete { task_id, .. } + | LifecycleEvent::ExecBefore { task_id, .. } + | LifecycleEvent::ExecAfter { task_id, .. } + | LifecycleEvent::PatchBefore { task_id, .. } + | LifecycleEvent::PatchAfter { task_id, .. } + | LifecycleEvent::McpToolBefore { task_id, .. } + | LifecycleEvent::McpToolAfter { task_id, .. } + | LifecycleEvent::AgentMessage { task_id, .. } => Some(task_id), + LifecycleEvent::ErrorOccurred { task_id, .. } => task_id.as_deref(), + } + } + + /// Get the timestamp for this event. + pub fn timestamp(&self) -> chrono::DateTime { + match self { + LifecycleEvent::SessionStart { timestamp, .. } + | LifecycleEvent::SessionEnd { timestamp, .. } + | LifecycleEvent::TaskStart { timestamp, .. } + | LifecycleEvent::TaskComplete { timestamp, .. } + | LifecycleEvent::ExecBefore { timestamp, .. } + | LifecycleEvent::ExecAfter { timestamp, .. } + | LifecycleEvent::PatchBefore { timestamp, .. } + | LifecycleEvent::PatchAfter { timestamp, .. } + | LifecycleEvent::McpToolBefore { timestamp, .. } + | LifecycleEvent::McpToolAfter { timestamp, .. } + | LifecycleEvent::AgentMessage { timestamp, .. } + | LifecycleEvent::ErrorOccurred { timestamp, .. } => *timestamp, + } + } +} + +/// Types of hooks that can be executed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HookType { + /// Execute a shell script or command. + Script { + command: Vec, + cwd: Option, + environment: HashMap, + timeout: Option, + }, + /// Send an HTTP request to a webhook URL. + Webhook { + url: String, + method: HttpMethod, + headers: HashMap, + timeout: Option, + retry_count: Option, + }, + /// Call an MCP tool. + McpTool { + server: String, + tool: String, + timeout: Option, + }, + /// Execute a custom binary/executable. + Executable { + path: PathBuf, + args: Vec, + cwd: Option, + environment: HashMap, + timeout: Option, + }, +} + +/// HTTP methods for webhook hooks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, +} + +/// Hook execution modes. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum HookExecutionMode { + /// Execute the hook asynchronously without blocking. + Async, + /// Execute the hook synchronously and wait for completion. + Blocking, + /// Execute the hook and ignore the result (fire-and-forget). + FireAndForget, +} + +impl Default for HookExecutionMode { + fn default() -> Self { + HookExecutionMode::Async + } +} + +/// Result of hook execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookResult { + pub success: bool, + pub output: Option, + pub error: Option, + pub duration: Duration, + pub metadata: HashMap, +} + +impl HookResult { + /// Create a successful hook result. + pub fn success(output: Option, duration: Duration) -> Self { + Self { + success: true, + output, + error: None, + duration, + metadata: HashMap::new(), + } + } + + /// Create a failed hook result. + pub fn failure(error: String, duration: Duration) -> Self { + Self { + success: false, + output: None, + error: Some(error), + duration, + metadata: HashMap::new(), + } + } + + /// Add metadata to the hook result. + pub fn with_metadata(mut self, key: String, value: serde_json::Value) -> Self { + self.metadata.insert(key, value); + self + } +} + +/// Errors that can occur in the hooks system. +#[derive(Error, Debug)] +pub enum HookError { + #[error("Hook configuration error: {0}")] + Configuration(String), + + #[error("Hook execution error: {0}")] + Execution(String), + + #[error("Hook timeout: {0}")] + Timeout(String), + + #[error("Hook validation error: {0}")] + Validation(String), + + #[error("Hook registry error: {0}")] + Registry(String), + + #[error("Hook context error: {0}")] + Context(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("HTTP error: {0}")] + Http(String), + + #[error("MCP error: {0}")] + Mcp(String), +} + +/// Hook execution priority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct HookPriority(pub u32); + +impl Default for HookPriority { + fn default() -> Self { + HookPriority(100) // Default priority + } +} + +impl HookPriority { + pub const HIGHEST: HookPriority = HookPriority(0); + pub const HIGH: HookPriority = HookPriority(25); + pub const NORMAL: HookPriority = HookPriority(100); + pub const LOW: HookPriority = HookPriority(200); + pub const LOWEST: HookPriority = HookPriority(u32::MAX); + + /// Get the numeric value of this priority. + pub fn value(&self) -> u32 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lifecycle_event_type() { + let event = LifecycleEvent::TaskStart { + task_id: "test".to_string(), + session_id: "session".to_string(), + prompt: "test prompt".to_string(), + timestamp: chrono::Utc::now(), + }; + assert_eq!(event.event_type(), LifecycleEventType::TaskStart); + assert_eq!(event.task_id(), Some("test")); + } + + #[test] + fn test_hook_result() { + let result = HookResult::success(Some("output".to_string()), Duration::from_secs(1)); + assert!(result.success); + assert_eq!(result.output, Some("output".to_string())); + + let result = HookResult::failure("error".to_string(), Duration::from_secs(1)); + assert!(!result.success); + assert_eq!(result.error, Some("error".to_string())); + } + + #[test] + fn test_hook_priority_ordering() { + assert!(HookPriority::HIGHEST < HookPriority::HIGH); + assert!(HookPriority::HIGH < HookPriority::NORMAL); + assert!(HookPriority::NORMAL < HookPriority::LOW); + assert!(HookPriority::LOW < HookPriority::LOWEST); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 8398ff7650a..ff4bc2f736b 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ pub mod error; pub mod exec; pub mod exec_env; mod flags; +pub mod hooks; mod is_safe_command; mod mcp_connection_manager; mod mcp_tool_call; diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 2a922cba6cd..63745a5f390 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -356,6 +356,14 @@ pub enum EventMsg { /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), + + /// Lifecycle hook execution events for monitoring + HookExecutionBegin(HookExecutionBeginEvent), + HookExecutionEnd(HookExecutionEndEvent), + + /// Session lifecycle events + SessionStart(SessionStartEvent), + SessionEnd(SessionEndEvent), } // Individual event payload types matching each `EventMsg` variant. @@ -482,6 +490,72 @@ pub struct GetHistoryEntryResponseEvent { pub entry: Option, } +/// Hook execution begin event for monitoring hook execution +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HookExecutionBeginEvent { + /// Unique identifier for this hook execution + pub execution_id: String, + /// Type of lifecycle event that triggered this hook + pub event_type: String, + /// Type of hook being executed (script, webhook, mcp_tool, executable) + pub hook_type: String, + /// Description of the hook being executed + pub hook_description: Option, + /// Execution mode (blocking, async, fire_and_forget) + pub execution_mode: String, + /// Priority of the hook + pub priority: u32, + /// Whether this hook is required (failure stops execution) + pub required: bool, + /// Timestamp when execution began + pub timestamp: String, +} + +/// Hook execution end event for monitoring hook execution results +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HookExecutionEndEvent { + /// Unique identifier for this hook execution (matches HookExecutionBeginEvent) + pub execution_id: String, + /// Whether the hook executed successfully + pub success: bool, + /// Output from the hook execution (if any) + pub output: Option, + /// Error message if the hook failed + pub error: Option, + /// Duration of hook execution in milliseconds + pub duration_ms: u64, + /// Number of retry attempts made + pub retry_attempts: u32, + /// Whether the execution was cancelled + pub cancelled: bool, + /// Timestamp when execution ended + pub timestamp: String, +} + +/// Session start event for lifecycle tracking +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SessionStartEvent { + /// Session identifier + pub session_id: String, + /// Model being used for this session + pub model: String, + /// Current working directory + pub cwd: PathBuf, + /// Session start timestamp + pub timestamp: String, +} + +/// Session end event for lifecycle tracking +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SessionEndEvent { + /// Session identifier + pub session_id: String, + /// Total session duration in milliseconds + pub duration_ms: u64, + /// Session end timestamp + pub timestamp: String, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct SessionConfiguredEvent { /// Unique id for this session. diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 676b47d64f7..0afc96954de 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,6 +1,8 @@ use chrono::Utc; use codex_common::elapsed::format_elapsed; use codex_core::config::Config; +use codex_core::hooks::manager::HookManager; +use codex_core::hooks::types::LifecycleEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::BackgroundEventEvent; use codex_core::protocol::ErrorEvent; @@ -33,6 +35,9 @@ pub(crate) struct EventProcessor { /// received. call_id_to_tool_call: HashMap, + /// Hook manager for lifecycle events + hook_manager: Option>, + // To ensure that --color=never is respected, ANSI escapes _must_ be added // using .style() with one of these fields. If you need a new style, add a // new field here. @@ -45,32 +50,43 @@ pub(crate) struct EventProcessor { } impl EventProcessor { - pub(crate) fn create_with_ansi(with_ansi: bool) -> Self { + pub(crate) async fn create_with_ansi(with_ansi: bool, config: &Config) -> Self { let call_id_to_command = HashMap::new(); let call_id_to_patch = HashMap::new(); let call_id_to_tool_call = HashMap::new(); + // Initialize hook manager + let hook_manager = match HookManager::new(config.hooks.clone()).await { + Ok(manager) => Some(std::sync::Arc::new(manager)), + Err(e) => { + eprintln!("Warning: Failed to initialize hook manager: {}", e); + None + } + }; + if with_ansi { Self { call_id_to_command, call_id_to_patch, + call_id_to_tool_call, + hook_manager, bold: Style::new().bold(), dimmed: Style::new().dimmed(), magenta: Style::new().magenta(), red: Style::new().red(), green: Style::new().green(), - call_id_to_tool_call, } } else { Self { call_id_to_command, call_id_to_patch, + call_id_to_tool_call, + hook_manager, bold: Style::new(), dimmed: Style::new(), magenta: Style::new(), red: Style::new(), green: Style::new(), - call_id_to_tool_call, } } } @@ -160,6 +176,26 @@ impl EventProcessor { start_time: Instant::now(), }, ); + + // Trigger ExecBefore lifecycle hook + if let Some(ref hook_manager) = self.hook_manager { + let exec_before_event = LifecycleEvent::ExecBefore { + call_id: call_id.clone(), + task_id: "exec".to_string(), // TODO: Get actual task ID + command: command.clone(), + cwd: cwd.clone(), + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = std::sync::Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(exec_before_event).await { + eprintln!("Warning: Failed to execute ExecBefore hooks: {}", e); + } + }); + } + ts_println!( "{} {} in {}", "exec".style(self.magenta), @@ -174,17 +210,42 @@ impl EventProcessor { exit_code, }) => { let exec_command = self.call_id_to_command.remove(&call_id); - let (duration, call) = if let Some(ExecCommandBegin { + let (duration, call, _command_for_hook) = if let Some(ExecCommandBegin { command, start_time, }) = exec_command { + let duration_value = start_time.elapsed(); + + // Trigger ExecAfter lifecycle hook + if let Some(ref hook_manager) = self.hook_manager { + let exec_after_event = LifecycleEvent::ExecAfter { + call_id: call_id.clone(), + task_id: "exec".to_string(), // TODO: Get actual task ID + command: command.clone(), + exit_code: exit_code, + stdout: stdout.clone(), + stderr: stderr.clone(), + duration: duration_value, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = std::sync::Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(exec_after_event).await { + eprintln!("Warning: Failed to execute ExecAfter hooks: {}", e); + } + }); + } + ( format!(" in {}", format_elapsed(start_time)), format!("{}", escape_command(&command).style(self.bold)), + Some(command), ) } else { - ("".to_string(), format!("exec('{call_id}')")) + ("".to_string(), format!("exec('{call_id}')"), None) }; let output = if exit_code == 0 { stdout } else { stderr }; @@ -236,6 +297,26 @@ impl EventProcessor { }, ); + // Trigger McpToolBefore lifecycle hook + if let Some(ref hook_manager) = self.hook_manager { + let mcp_before_event = LifecycleEvent::McpToolBefore { + call_id: call_id.clone(), + task_id: "mcp_tool".to_string(), // TODO: Get actual task ID + server: server.clone(), + tool: tool.clone(), + arguments: arguments.clone(), + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = std::sync::Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(mcp_before_event).await { + eprintln!("Warning: Failed to execute McpToolBefore hooks: {}", e); + } + }); + } + ts_println!( "{} {}", "tool".style(self.magenta), @@ -250,6 +331,45 @@ impl EventProcessor { // Retrieve start time and invocation for duration calculation and labeling. let info = self.call_id_to_tool_call.remove(&call_id); + // Trigger McpToolAfter lifecycle hook + if let Some(ref hook_manager) = self.hook_manager { + let duration_value = info.as_ref() + .map(|i| i.start_time.elapsed()) + .unwrap_or_default(); + + // Extract server and tool from the invocation (simplified approach) + let (server, tool) = if let Some(ref info) = info { + // Parse server.tool from invocation like "server.tool(args)" + let parts: Vec<&str> = info.invocation.split('(').next().unwrap_or("").split('.').collect(); + if parts.len() >= 2 { + (parts[0].to_string(), parts[1].to_string()) + } else { + ("unknown".to_string(), "unknown".to_string()) + } + } else { + ("unknown".to_string(), "unknown".to_string()) + }; + + let mcp_after_event = LifecycleEvent::McpToolAfter { + call_id: call_id.clone(), + task_id: "mcp_tool".to_string(), // TODO: Get actual task ID + server, + tool, + success: success, + result: result.as_ref().map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null)), + duration: duration_value, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = std::sync::Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(mcp_after_event).await { + eprintln!("Warning: Failed to execute McpToolAfter hooks: {}", e); + } + }); + } + let (duration, invocation) = if let Some(McpToolCallBegin { invocation, start_time, @@ -292,6 +412,36 @@ impl EventProcessor { }, ); + // Trigger PatchBefore lifecycle hook + if let Some(ref hook_manager) = self.hook_manager { + // Convert FileChange to String descriptions + let changes_descriptions: std::collections::HashMap = changes.iter() + .map(|(path, change)| { + let description = match change { + codex_core::protocol::FileChange::Add { .. } => "add".to_string(), + codex_core::protocol::FileChange::Delete => "delete".to_string(), + codex_core::protocol::FileChange::Update { .. } => "update".to_string(), + }; + (path.clone(), description) + }) + .collect(); + + let patch_before_event = LifecycleEvent::PatchBefore { + call_id: call_id.clone(), + task_id: "patch".to_string(), // TODO: Get actual task ID + changes: changes_descriptions, + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = std::sync::Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(patch_before_event).await { + eprintln!("Warning: Failed to execute PatchBefore hooks: {}", e); + } + }); + } + ts_println!( "{} auto_approved={}:", "apply_patch".style(self.magenta), @@ -363,6 +513,35 @@ impl EventProcessor { }) => { let patch_begin = self.call_id_to_patch.remove(&call_id); + // Trigger PatchAfter lifecycle hook + if let Some(ref hook_manager) = self.hook_manager { + // Extract applied files from stdout (this is a simplified approach) + let applied_files: Vec = if success { + // In a real implementation, we'd parse the actual applied files + // For now, we'll use a placeholder + vec![std::path::PathBuf::from("patch_applied")] + } else { + vec![] + }; + + let patch_after_event = LifecycleEvent::PatchAfter { + call_id: call_id.clone(), + task_id: "patch".to_string(), // TODO: Get actual task ID + applied_files, + success: success, + duration: patch_begin.as_ref().map(|p| p.start_time.elapsed()).unwrap_or_default(), + timestamp: chrono::Utc::now(), + }; + + // Execute hooks asynchronously + let hook_manager_clone = std::sync::Arc::clone(hook_manager); + tokio::spawn(async move { + if let Err(e) = hook_manager_clone.trigger_event(patch_after_event).await { + eprintln!("Warning: Failed to execute PatchAfter hooks: {}", e); + } + }); + } + // Compute duration and summary label similar to exec commands. let (duration, label) = if let Some(PatchApplyBegin { start_time, @@ -418,6 +597,18 @@ impl EventProcessor { EventMsg::GetHistoryEntryResponse(_) => { // Currently ignored in exec output. } + EventMsg::HookExecutionBegin(_) => { + // Hook execution events are currently ignored in exec output. + } + EventMsg::HookExecutionEnd(_) => { + // Hook execution events are currently ignored in exec output. + } + EventMsg::SessionStart(_) => { + // Session lifecycle events are currently ignored in exec output. + } + EventMsg::SessionEnd(_) => { + // Session lifecycle events are currently ignored in exec output. + } } } } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index dbf01f025b9..a5fa060f502 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -95,7 +95,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .with_writer(std::io::stderr) .try_init(); - let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?; + let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config.clone()).await?; let codex = Arc::new(codex_wrapper); info!("Codex initialized with event: {event:?}"); @@ -164,7 +164,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any info!("Sent prompt with event ID: {initial_prompt_task_id}"); // Run the loop until the task is complete. - let mut event_processor = EventProcessor::create_with_ansi(stdout_with_ansi); + let mut event_processor = EventProcessor::create_with_ansi(stdout_with_ansi, &config).await; while let Some(event) = rx.recv().await { let (is_last_event, last_assistant_message) = match &event.msg { EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 67c990b00c4..58725e24fd0 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -170,7 +170,11 @@ pub async fn run_codex_tool_session( | EventMsg::BackgroundEvent(_) | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) - | EventMsg::GetHistoryEntryResponse(_) => { + | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::HookExecutionBegin(_) + | EventMsg::HookExecutionEnd(_) + | EventMsg::SessionStart(_) + | EventMsg::SessionEnd(_) => { // For now, we do not do anything extra for these // events. Note that // send(codex_event_to_notification(&event)) above has