Skip to main content

cpex_hosts_python/
factory.rs

1// Location: ./crates/cpex-hosts-python/src/factory.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// The `isolated_venv` plugin factory.
7//
8// Hosts register this before `PluginManager::from_config()`. For each config
9// entry whose `kind:` is `isolated_venv`, the manager calls `create()`, which
10// parses the venv settings out of the entry's opaque `config` map and returns
11// the plugin plus one handler per declared hook.
12
13use std::sync::Arc;
14
15use cpex_core::{
16    error::PluginError,
17    factory::{PluginFactory, PluginInstance},
18    plugin::PluginConfig,
19};
20
21use crate::plugin::{IsolatedPythonPlugin, PythonHookAdapter};
22
23/// `kind:` string operators write in CPEX YAML to declare a Python plugin
24/// that runs out-of-process in its own virtualenv.
25///
26/// This supersedes issue #20's `python://` in-process framing: the host runs
27/// the plugin in a subprocess, not via PyO3, so the runtime never links
28/// libpython and a plugin crash cannot take the gateway down.
29pub const KIND: &str = "isolated_venv";
30
31/// Creates out-of-process Python plugins from `isolated_venv` config entries.
32pub struct IsolatedVenvFactory;
33
34/// Whether an entry sets the ignored `plugin_dirs` key in its `config:` block.
35///
36/// Split out from the warning call so the *decision* is directly assertable —
37/// the `tracing::warn!` itself is a no-op without a subscriber, and the
38/// workspace carries none in its test profile.
39fn declares_ignored_plugin_dirs(config: &PluginConfig) -> bool {
40    config
41        .config
42        .as_ref()
43        .and_then(serde_json::Value::as_object)
44        .is_some_and(|block| block.contains_key("plugin_dirs"))
45}
46
47impl PluginFactory for IsolatedVenvFactory {
48    fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
49        if config.hooks.is_empty() {
50            return Err(PluginError::Config {
51                message: format!(
52                    "plugin '{}' (isolated_venv): `hooks:` must list at least one hook \
53                     for the Python plugin to handle (e.g. tool_pre_invoke)",
54                    config.name
55                ),
56            }
57            .boxed());
58        }
59
60        // `plugin_dirs` is no longer configurable — the host always uses
61        // `<project root>/plugins`. An entry that still sets it would otherwise
62        // appear to work while pointing somewhere else entirely, so say so once,
63        // naming the plugin.
64        if declares_ignored_plugin_dirs(config) {
65            tracing::warn!(
66                plugin = %config.name,
67                "plugin '{}' (isolated_venv) sets `plugin_dirs` in its config block, but the \
68                 host always uses '{}' at the project root. Setting ignored — remove it.",
69                config.name,
70                crate::plugin::DEFAULT_PLUGIN_DIR,
71            );
72        }
73
74        // Parsing happens here rather than in `initialize()` so a malformed
75        // entry fails at config load — while the manager can still report
76        // which plugin was bad — instead of midway through a rollback.
77        let plugin = Arc::new(IsolatedPythonPlugin::from_config(config)?);
78
79        let handlers: Vec<_> = config
80            .hooks
81            .iter()
82            .map(
83                |hook| -> (&'static str, Arc<dyn cpex_core::registry::AnyHookHandler>) {
84                    // The registry keys handlers by `&'static str`, and hook names
85                    // arrive as owned Strings from YAML. Leaking is what
86                    // audit-logger does: the set is bounded by config size and
87                    // lives as long as the process holds the plugin anyway.
88                    let leaked: &'static str = Box::leak(hook.clone().into_boxed_str());
89                    (
90                        leaked,
91                        Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)),
92                    )
93                },
94            )
95            .collect();
96
97        Ok(PluginInstance { plugin, handlers })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use cpex_core::config::CpexConfig;
104    use cpex_core::factory::PluginFactoryRegistry;
105    use cpex_core::manager::PluginManager;
106
107    use super::*;
108
109    /// A config with one `isolated_venv` plugin, mirroring the YAML shape an
110    /// operator writes. No `plugin_dirs`: the host always resolves
111    /// `<project root>/plugins` — see `plugin::DEFAULT_PLUGIN_DIR`.
112    fn minimal_config_yaml() -> &'static str {
113        r#"
114plugins:
115  - name: pii-filter
116    kind: isolated_venv
117    hooks: [tool_pre_invoke]
118    config:
119      class_name: my_pkg.filters.PiiFilter
120"#
121    }
122
123    fn registry() -> PluginFactoryRegistry {
124        let mut factories = PluginFactoryRegistry::new();
125        factories.register(KIND, Box::new(IsolatedVenvFactory));
126        factories
127    }
128
129    #[test]
130    fn factory_registers_under_isolated_venv_kind() {
131        let factories = registry();
132        assert!(factories.has(KIND));
133        assert_eq!(KIND, "isolated_venv");
134    }
135
136    #[test]
137    fn loading_an_isolated_venv_config_succeeds() {
138        let config: CpexConfig = serde_yaml::from_str(minimal_config_yaml()).expect("valid YAML");
139        let factories = registry();
140
141        // The load path is what would raise "no factory registered for plugin
142        // kind 'isolated_venv'" if registration did not take.
143        PluginManager::from_config(config, &factories)
144            .expect("config loads with the factory registered");
145    }
146
147    #[test]
148    fn missing_class_name_is_a_config_error_not_a_panic() {
149        // The block carries only the now-ignored `plugin_dirs`, so this also
150        // covers that an ignored key does not mask the missing required one.
151        let yaml = r#"
152plugins:
153  - name: broken
154    kind: isolated_venv
155    hooks: [tool_pre_invoke]
156    config:
157      plugin_dirs: ["./plugins"]
158"#;
159        let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML");
160        // `PluginManager` is not Debug, so `expect_err` is unavailable here.
161        let Err(err) = PluginManager::from_config(config, &registry()) else {
162            panic!("a config block without class_name must be rejected");
163        };
164
165        assert!(
166            matches!(*err, PluginError::Config { .. }),
167            "expected a Config error, got: {err:?}"
168        );
169        let msg = err.to_string();
170        assert!(
171            msg.contains("class_name"),
172            "the error should name the missing field so an operator can fix it: {msg}"
173        );
174    }
175
176    #[test]
177    fn empty_hooks_list_is_rejected() {
178        let yaml = r#"
179plugins:
180  - name: no-hooks
181    kind: isolated_venv
182    hooks: []
183    config:
184      class_name: my_pkg.filters.PiiFilter
185"#;
186        let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML");
187        let Err(err) = PluginManager::from_config(config, &registry()) else {
188            panic!("a plugin declaring no hooks can never be invoked");
189        };
190        assert!(matches!(*err, PluginError::Config { .. }));
191    }
192
193    #[test]
194    fn the_ignored_plugin_dirs_key_is_detected_for_the_warning() {
195        // Guards the warning's trigger condition. Without this an operator
196        // upgrading gets silence: the key stops working and nothing says so.
197        let with_key = PluginConfig {
198            name: "legacy".into(),
199            kind: KIND.into(),
200            hooks: vec!["tool_pre_invoke".into()],
201            config: Some(serde_json::json!({
202                "class_name": "my_pkg.P",
203                "plugin_dirs": ["/somewhere"],
204            })),
205            ..Default::default()
206        };
207        assert!(declares_ignored_plugin_dirs(&with_key));
208
209        let without_key = PluginConfig {
210            config: Some(serde_json::json!({ "class_name": "my_pkg.P" })),
211            ..with_key.clone()
212        };
213        assert!(
214            !declares_ignored_plugin_dirs(&without_key),
215            "a clean config must not warn"
216        );
217
218        // An absent block must not panic or warn.
219        let no_block = PluginConfig {
220            config: None,
221            ..with_key
222        };
223        assert!(!declares_ignored_plugin_dirs(&no_block));
224    }
225
226    #[test]
227    fn an_ignored_plugin_dirs_key_still_loads() {
228        // An operator upgrading from a config that set `plugin_dirs` must not
229        // hit a load failure — the key is ignored (with a warning), not
230        // rejected. A hard error here would break every existing config.
231        let yaml = r#"
232plugins:
233  - name: legacy-dirs
234    kind: isolated_venv
235    hooks: [tool_pre_invoke]
236    config:
237      class_name: my_pkg.filters.PiiFilter
238      plugin_dirs: ["/somewhere/else"]
239"#;
240        let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML");
241        PluginManager::from_config(config, &registry())
242            .expect("an ignored plugin_dirs key must not fail the load");
243    }
244
245    #[test]
246    fn one_handler_is_produced_per_declared_hook() {
247        let config = PluginConfig {
248            name: "multi".into(),
249            kind: KIND.into(),
250            hooks: vec![
251                "tool_pre_invoke".into(),
252                "tool_post_invoke".into(),
253                "cmf.tool_pre_invoke".into(),
254            ],
255            config: Some(serde_json::json!({ "class_name": "my_pkg.Multi" })),
256            ..Default::default()
257        };
258
259        let instance = IsolatedVenvFactory
260            .create(&config)
261            .expect("a valid multi-hook entry creates an instance");
262
263        assert_eq!(instance.handlers.len(), 3);
264        let names: Vec<&str> = instance.handlers.iter().map(|(n, _)| *n).collect();
265        assert!(names.contains(&"tool_pre_invoke"));
266        assert!(names.contains(&"cmf.tool_pre_invoke"));
267    }
268}