Skip to main content

lean_ctx/core/plugins/
executor.rs

1use std::process::Stdio;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6use super::registry::Plugin;
7
8#[derive(Debug, Clone, Serialize)]
9#[serde(tag = "hook", rename_all = "snake_case")]
10pub enum HookPoint {
11    OnSessionStart,
12    OnSessionEnd,
13    PreRead {
14        path: String,
15    },
16    PostCompress {
17        path: String,
18        original_tokens: usize,
19        compressed_tokens: usize,
20    },
21    OnKnowledgeUpdate {
22        fact_id: String,
23    },
24}
25
26impl HookPoint {
27    pub fn hook_name(&self) -> &'static str {
28        match self {
29            Self::OnSessionStart => "on_session_start",
30            Self::OnSessionEnd => "on_session_end",
31            Self::PreRead { .. } => "pre_read",
32            Self::PostCompress { .. } => "post_compress",
33            Self::OnKnowledgeUpdate { .. } => "on_knowledge_update",
34        }
35    }
36
37    pub fn all_hook_names() -> &'static [&'static str] {
38        &[
39            "on_session_start",
40            "on_session_end",
41            "pre_read",
42            "post_compress",
43            "on_knowledge_update",
44        ]
45    }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HookResult {
50    pub plugin_name: String,
51    pub success: bool,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub output: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub error: Option<String>,
56    pub duration_ms: u64,
57}
58
59pub fn execute_hook_sync(plugin: &Plugin, hook: &HookPoint) -> HookResult {
60    let hook_name = hook.hook_name();
61    let plugin_name = plugin.manifest.plugin.name.clone();
62
63    let Some(entry) = plugin.manifest.hooks.get(hook_name) else {
64        return HookResult {
65            plugin_name,
66            success: true,
67            output: None,
68            error: None,
69            duration_ms: 0,
70        };
71    };
72
73    let timeout = Duration::from_millis(entry.timeout_ms);
74    let start = std::time::Instant::now();
75
76    let hook_json = match serde_json::to_string(hook) {
77        Ok(j) => j,
78        Err(e) => {
79            return HookResult {
80                plugin_name,
81                success: false,
82                output: None,
83                error: Some(format!("failed to serialize hook data: {e}")),
84                duration_ms: start.elapsed().as_millis() as u64,
85            };
86        }
87    };
88
89    let result = run_subprocess(
90        &entry.command,
91        &plugin.path,
92        &[("LEAN_CTX_HOOK", hook_name)],
93        &hook_json,
94        timeout,
95        &plugin.manifest.trust.policy(),
96    );
97    let duration_ms = start.elapsed().as_millis() as u64;
98
99    match result {
100        Ok(output) => {
101            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
102            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
103            let success = output.status.success();
104            HookResult {
105                plugin_name,
106                success,
107                output: if stdout.is_empty() {
108                    None
109                } else {
110                    Some(stdout)
111                },
112                error: if stderr.is_empty() && success {
113                    None
114                } else if !stderr.is_empty() {
115                    Some(stderr)
116                } else {
117                    Some(format!("exit code: {}", output.status))
118                },
119                duration_ms,
120            }
121        }
122        Err(e) => HookResult {
123            plugin_name,
124            success: false,
125            output: None,
126            error: Some(e),
127            duration_ms,
128        },
129    }
130}
131
132/// Spawn `command` (whitespace-split into program + args) as a sandboxed child:
133/// piped stdio, `LEAN_CTX_PLUGIN_DIR` exported, plus any `extra_env`. The
134/// `stdin_data` is written to the child's stdin and the process is bounded by
135/// `timeout`. The [`SandboxPolicy`] is applied before spawn (env scrub + cwd
136/// jail; EPIC 12.3). Shared by hook execution and manifest-declared tool
137/// invocation (EPIC 12.11) so both honor the same isolation contract.
138pub(crate) fn run_subprocess(
139    command: &str,
140    plugin_dir: &std::path::Path,
141    extra_env: &[(&str, &str)],
142    stdin_data: &str,
143    timeout: Duration,
144    policy: &super::sandbox::SandboxPolicy,
145) -> Result<std::process::Output, String> {
146    let parts: Vec<&str> = command.split_whitespace().collect();
147    let Some((program, args)) = parts.split_first() else {
148        return Err("empty command".to_string());
149    };
150
151    let mut cmd = std::process::Command::new(program);
152    cmd.args(args)
153        .stdin(Stdio::piped())
154        .stdout(Stdio::piped())
155        .stderr(Stdio::piped());
156    // Apply the sandbox (env scrub + cwd jail) first, then set the trusted
157    // lean-ctx env + caller extras so they always win over the scrubbed base.
158    policy.apply(&mut cmd, plugin_dir);
159    cmd.env("LEAN_CTX_PLUGIN_DIR", plugin_dir);
160    for (key, value) in extra_env {
161        cmd.env(key, value);
162    }
163
164    let mut child = cmd.spawn().map_err(|e| format!("failed to spawn: {e}"))?;
165
166    if let Some(ref mut stdin) = child.stdin.take() {
167        use std::io::Write;
168        let _ = stdin.write_all(stdin_data.as_bytes());
169    }
170
171    wait_with_timeout(&mut child, timeout)
172}
173
174fn wait_with_timeout(
175    child: &mut std::process::Child,
176    timeout: Duration,
177) -> Result<std::process::Output, String> {
178    let deadline = std::time::Instant::now() + timeout;
179    loop {
180        match child.try_wait() {
181            Ok(Some(status)) => {
182                let stdout = child
183                    .stdout
184                    .take()
185                    .map(|mut s| {
186                        use std::io::Read;
187                        let mut buf = Vec::new();
188                        let _ = s.read_to_end(&mut buf);
189                        buf
190                    })
191                    .unwrap_or_default();
192                let stderr = child
193                    .stderr
194                    .take()
195                    .map(|mut s| {
196                        use std::io::Read;
197                        let mut buf = Vec::new();
198                        let _ = s.read_to_end(&mut buf);
199                        buf
200                    })
201                    .unwrap_or_default();
202                return Ok(std::process::Output {
203                    status,
204                    stdout,
205                    stderr,
206                });
207            }
208            Ok(None) => {
209                if std::time::Instant::now() >= deadline {
210                    let _ = child.kill();
211                    return Err(format!("timeout after {}ms", timeout.as_millis()));
212                }
213                std::thread::sleep(Duration::from_millis(10));
214            }
215            Err(e) => return Err(format!("wait error: {e}")),
216        }
217    }
218}
219
220pub fn execute_hooks_for_point(plugins: &[&Plugin], hook: &HookPoint) -> Vec<HookResult> {
221    let hook_name = hook.hook_name();
222    plugins
223        .iter()
224        .filter(|p| p.enabled && p.manifest.hooks.contains_key(hook_name))
225        .map(|p| execute_hook_sync(p, hook))
226        .collect()
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn hook_point_names() {
235        assert_eq!(HookPoint::OnSessionStart.hook_name(), "on_session_start");
236        assert_eq!(HookPoint::OnSessionEnd.hook_name(), "on_session_end");
237        assert_eq!(
238            HookPoint::PreRead { path: "x".into() }.hook_name(),
239            "pre_read"
240        );
241        assert_eq!(
242            HookPoint::PostCompress {
243                path: "x".into(),
244                original_tokens: 100,
245                compressed_tokens: 50,
246            }
247            .hook_name(),
248            "post_compress"
249        );
250        assert_eq!(
251            HookPoint::OnKnowledgeUpdate {
252                fact_id: "f1".into()
253            }
254            .hook_name(),
255            "on_knowledge_update"
256        );
257    }
258
259    #[test]
260    fn all_hook_names_complete() {
261        let names = HookPoint::all_hook_names();
262        assert_eq!(names.len(), 5);
263        assert!(names.contains(&"on_session_start"));
264        assert!(names.contains(&"pre_read"));
265        assert!(names.contains(&"post_compress"));
266    }
267
268    #[test]
269    fn hook_point_serializes_to_json() {
270        let hook = HookPoint::PostCompress {
271            path: "/tmp/file.rs".into(),
272            original_tokens: 1000,
273            compressed_tokens: 200,
274        };
275        let json = serde_json::to_string(&hook).unwrap();
276        assert!(json.contains("post_compress"));
277        assert!(json.contains("1000"));
278        assert!(json.contains("200"));
279    }
280
281    #[test]
282    fn execute_missing_hook_is_noop() {
283        let manifest = crate::core::plugins::manifest::PluginManifest::from_str(
284            r#"
285[plugin]
286name = "no-hooks"
287version = "1.0.0"
288"#,
289            &std::path::PathBuf::from("test.toml"),
290        )
291        .unwrap();
292
293        let plugin = Plugin {
294            manifest,
295            enabled: true,
296            path: std::path::PathBuf::from("/tmp/no-hooks"),
297        };
298
299        let result = execute_hook_sync(&plugin, &HookPoint::OnSessionStart);
300        assert!(result.success);
301        assert_eq!(result.duration_ms, 0);
302    }
303
304    #[test]
305    fn execute_nonexistent_binary_fails() {
306        let manifest = crate::core::plugins::manifest::PluginManifest::from_str(
307            r#"
308[plugin]
309name = "bad-binary"
310version = "1.0.0"
311
312[hooks.on_session_start]
313command = "__nonexistent_lean_ctx_test_binary__ start"
314timeout_ms = 1000
315"#,
316            &std::path::PathBuf::from("test.toml"),
317        )
318        .unwrap();
319
320        let plugin = Plugin {
321            manifest,
322            enabled: true,
323            path: std::path::PathBuf::from("/tmp/bad-binary"),
324        };
325
326        let result = execute_hook_sync(&plugin, &HookPoint::OnSessionStart);
327        assert!(!result.success);
328        assert!(result.error.unwrap().contains("failed to spawn"));
329    }
330
331    #[cfg(unix)]
332    #[test]
333    fn run_subprocess_echoes_stdin() {
334        let out = run_subprocess(
335            "cat",
336            std::path::Path::new("/tmp"),
337            &[("LEAN_CTX_TOOL", "demo")],
338            "hello-stdin",
339            Duration::from_secs(2),
340            &super::super::sandbox::SandboxPolicy::strict(),
341        )
342        .unwrap();
343        assert!(out.status.success());
344        assert_eq!(String::from_utf8_lossy(&out.stdout), "hello-stdin");
345    }
346
347    #[test]
348    fn run_subprocess_empty_command_errors() {
349        let err = run_subprocess(
350            "   ",
351            std::path::Path::new("/tmp"),
352            &[],
353            "",
354            Duration::from_millis(500),
355            &super::super::sandbox::SandboxPolicy::strict(),
356        )
357        .unwrap_err();
358        assert!(err.contains("empty command"));
359    }
360
361    #[cfg(unix)]
362    #[test]
363    fn execute_echo_plugin_succeeds() {
364        let manifest = crate::core::plugins::manifest::PluginManifest::from_str(
365            r#"
366[plugin]
367name = "echo-plugin"
368version = "1.0.0"
369
370[hooks.on_session_start]
371command = "echo hello"
372timeout_ms = 2000
373"#,
374            &std::path::PathBuf::from("test.toml"),
375        )
376        .unwrap();
377
378        let plugin = Plugin {
379            manifest,
380            enabled: true,
381            path: std::path::PathBuf::from("/tmp/echo-plugin"),
382        };
383
384        let result = execute_hook_sync(&plugin, &HookPoint::OnSessionStart);
385        assert!(result.success);
386        assert!(result.output.unwrap().contains("hello"));
387    }
388}