Skip to main content

leviath_runtime/
script_provider.rs

1//! Lazy, hot-reloading resolution of Rhai *script providers*.
2//!
3//! Native providers are built eagerly at daemon startup. Script providers are
4//! not: a `.rhai` file in the providers directory becomes a live provider only
5//! when an agent references its name, and it is **reloaded automatically** when
6//! the file changes. [`ScriptProviderLayer`] is the seam the [`ProviderRegistry`]
7//! consults for any name it doesn't have natively; it caches compiled providers
8//! keyed by file mtime, so:
9//!
10//! - first reference to `<name>` → compile + `initialize` + cache;
11//! - unchanged file → cached instance (no recompile);
12//! - edited file (newer mtime) → rebuild;
13//! - a brand-new file that didn't exist at startup → loads on first reference;
14//! - a deleted file → evicted, the provider disappears;
15//! - a broken script → not resolved (logged), so selection falls through.
16//!
17//! [`ProviderRegistry`]: crate::ProviderRegistry
18
19use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::{Arc, Mutex, PoisonError};
22use std::time::SystemTime;
23
24use leviath_providers::{ModelCapabilities, Provider, RateLimitConfig, RhaiProvider};
25
26/// Per-provider configuration from `[model_providers.<name>]`. All fields are
27/// optional overrides - a script activates by an agent referencing its name and
28/// the file existing, not by having an entry here.
29#[derive(Clone, Debug, Default)]
30pub struct ScriptProviderSpec {
31    /// Script filename stem or path (default `<name>.rhai` in the providers dir).
32    pub script: Option<String>,
33    /// Rate limit applied by the Rust wrapper.
34    pub rate_limit: Option<RateLimitConfig>,
35    /// The `config` map passed to the script's `initialize` (base_url, api_key,
36    /// and any extra keys), pre-assembled by the CLI.
37    pub init_config: serde_json::Value,
38}
39
40/// A cached, compiled script provider plus the source mtime it was built from.
41struct Cached {
42    mtime: SystemTime,
43    provider: Arc<dyn Provider>,
44}
45
46/// Lazy, hot-reloading resolver for script providers.
47pub struct ScriptProviderLayer {
48    dir: PathBuf,
49    overrides: HashMap<String, ScriptProviderSpec>,
50    default_caps: HashMap<String, ModelCapabilities>,
51    request_timeout_secs: Option<u64>,
52    /// `[security] allow_env_vars`: credential-shaped environment variables a
53    /// provider script may read. Empty by default - a provider script runs
54    /// during inference, not through a tool call, so nothing it does passes an
55    /// approval prompt.
56    env_allowlist: Arc<Vec<String>>,
57    cache: Mutex<HashMap<String, Cached>>,
58}
59
60impl ScriptProviderLayer {
61    /// Build a layer over `dir`, with per-provider `overrides`, global model
62    /// capability overrides, and the global request timeout.
63    pub fn new(
64        dir: PathBuf,
65        overrides: HashMap<String, ScriptProviderSpec>,
66        default_caps: HashMap<String, ModelCapabilities>,
67        request_timeout_secs: Option<u64>,
68        env_allowlist: Vec<String>,
69    ) -> Self {
70        Self {
71            dir,
72            overrides,
73            default_caps,
74            request_timeout_secs,
75            env_allowlist: Arc::new(env_allowlist),
76            cache: Mutex::new(HashMap::new()),
77        }
78    }
79
80    /// Resolve `<name>` to its script path: an explicit `script` override
81    /// (an absolute path, or a stem/filename under the providers dir), else
82    /// `<name>.rhai` in the providers dir.
83    ///
84    /// A *relative* override is confined to the providers directory. It used to
85    /// be joined verbatim, so `script = "../../tools/evil"` reached outside it -
86    /// and whatever it reached is compiled and run as a provider, which is the
87    /// most privileged script surface there is. An absolute path is still
88    /// honored: that is the documented way to point at a script kept elsewhere,
89    /// and it can only come from the user's own config, not from a blueprint.
90    ///
91    /// `None` when the override escapes; the caller reports it and loads nothing.
92    fn resolve_path(&self, name: &str) -> Option<PathBuf> {
93        let stem = self
94            .overrides
95            .get(name)
96            .and_then(|s| s.script.as_deref())
97            .unwrap_or(name);
98        let candidate = PathBuf::from(stem);
99        if candidate.is_absolute() {
100            return Some(candidate);
101        }
102        let filename = match stem.ends_with(".rhai") {
103            true => stem.to_string(),
104            false => format!("{stem}.rhai"),
105        };
106        // Reject any traversal component outright rather than normalizing it
107        // away: a provider path has no legitimate reason to contain `..`, so
108        // "what did the user mean by this" is the wrong question to ask.
109        let joined = PathBuf::from(&filename);
110        if joined
111            .components()
112            .any(|c| matches!(c, std::path::Component::ParentDir))
113        {
114            return None;
115        }
116        Some(self.dir.join(joined))
117    }
118
119    /// Get (or lazily load / reload) the provider named `name`, or `None` when
120    /// there is no such script or it fails to load.
121    ///
122    /// The cache lock is taken three times - a read, then a write on whichever
123    /// arm the compile lands on - and is **never held across
124    /// `RhaiProvider::from_script`**, which parses and initializes an arbitrary
125    /// user-authored `.rhai` file. That call is the slowest and least
126    /// trustworthy thing this layer does; holding a process-wide lock across it
127    /// serialized every agent's provider lookup behind one compile, and a panic
128    /// inside it poisoned the cache for the whole daemon.
129    ///
130    /// The cost is that two callers racing on the same cold name may both
131    /// compile it. Both get a working provider and the later `insert` wins -
132    /// wasted work, never a wrong answer, and it self-corrects on the next
133    /// lookup because entries are validated by mtime.
134    pub fn get_or_load(&self, name: &str) -> Option<Arc<dyn Provider>> {
135        let Some(path) = self.resolve_path(name) else {
136            tracing::warn!(
137                provider = %name,
138                "script provider path escapes the providers directory - refusing to load"
139            );
140            self.evict(name);
141            return None;
142        };
143        let Some(mtime) = std::fs::metadata(&path).and_then(|m| m.modified()).ok() else {
144            // File gone (or unreadable): drop any stale entry, no provider.
145            self.evict(name);
146            return None;
147        };
148        if let Some(cached) = self.cached_fresh(name, mtime) {
149            return Some(cached);
150        }
151
152        let spec = self.overrides.get(name);
153        let init_config = spec
154            .map(|s| s.init_config.clone())
155            .unwrap_or_else(|| serde_json::json!({}));
156        let rate_limit = spec.and_then(|s| s.rate_limit.clone());
157        // No lock held here - see the note above.
158        match RhaiProvider::from_script(
159            name.to_string(),
160            &path,
161            init_config,
162            self.default_caps.clone(),
163            rate_limit,
164            self.request_timeout_secs,
165            self.env_allowlist.clone(),
166        ) {
167            Ok(p) => {
168                let provider: Arc<dyn Provider> = Arc::new(p);
169                self.cache
170                    .lock()
171                    .unwrap_or_else(PoisonError::into_inner)
172                    .insert(
173                        name.to_string(),
174                        Cached {
175                            mtime,
176                            provider: provider.clone(),
177                        },
178                    );
179                Some(provider)
180            }
181            Err(e) => {
182                self.evict(name);
183                tracing::warn!(provider = %name, error = %e, "script provider load failed");
184                None
185            }
186        }
187    }
188
189    /// The cached provider for `name`, but only if it was built from the script
190    /// as it is on disk right now. Holds the lock just long enough to clone an
191    /// `Arc`.
192    fn cached_fresh(&self, name: &str, mtime: SystemTime) -> Option<Arc<dyn Provider>> {
193        let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
194        let cached = cache.get(name)?;
195        if cached.mtime == mtime {
196            Some(cached.provider.clone())
197        } else {
198            None
199        }
200    }
201
202    /// Drop any cached entry for `name`.
203    fn evict(&self, name: &str) {
204        self.cache
205            .lock()
206            .unwrap_or_else(PoisonError::into_inner)
207            .remove(name);
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::time::Duration;
215
216    const GOOD: &str = "fn initialize(config) { #{ base: config.base_url } }\n\
217                        fn inference(state, request) { #{ content: \"ok\" } }";
218
219    fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
220        let path = dir.join(name);
221        std::fs::write(&path, body).unwrap();
222        path
223    }
224
225    /// Force a file's mtime to be strictly newer, so a reload is observable even
226    /// when two writes land in the same clock tick.
227    fn bump_mtime(path: &std::path::Path) {
228        let later = SystemTime::now() + Duration::from_secs(5);
229        let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
230        f.set_modified(later).unwrap();
231    }
232
233    fn layer(dir: PathBuf) -> ScriptProviderLayer {
234        ScriptProviderLayer::new(dir, HashMap::new(), HashMap::new(), None, Vec::new())
235    }
236
237    #[test]
238    fn loads_by_convention_and_caches() {
239        let dir = tempfile::tempdir().unwrap();
240        write(dir.path(), "groq.rhai", GOOD);
241        let l = layer(dir.path().to_path_buf());
242        let first = l.get_or_load("groq").expect("loads");
243        assert_eq!(first.name(), "groq");
244        // Cache hit returns the same Arc (unchanged mtime).
245        let second = l.get_or_load("groq").expect("cached");
246        assert!(Arc::ptr_eq(&first, &second));
247    }
248
249    #[test]
250    fn missing_file_is_none() {
251        let dir = tempfile::tempdir().unwrap();
252        let l = layer(dir.path().to_path_buf());
253        assert!(l.get_or_load("nope").is_none());
254    }
255
256    #[test]
257    fn hot_reload_on_mtime_change() {
258        let dir = tempfile::tempdir().unwrap();
259        let path = write(dir.path(), "p.rhai", GOOD);
260        let l = layer(dir.path().to_path_buf());
261        let first = l.get_or_load("p").unwrap();
262        // Rewrite with newer mtime → a fresh instance.
263        write(dir.path(), "p.rhai", GOOD);
264        bump_mtime(&path);
265        let second = l.get_or_load("p").unwrap();
266        assert!(!Arc::ptr_eq(&first, &second));
267    }
268
269    #[test]
270    fn new_file_after_construction_loads() {
271        let dir = tempfile::tempdir().unwrap();
272        let l = layer(dir.path().to_path_buf());
273        assert!(l.get_or_load("late").is_none());
274        write(dir.path(), "late.rhai", GOOD);
275        assert!(l.get_or_load("late").is_some());
276    }
277
278    #[test]
279    fn deleted_file_evicts() {
280        let dir = tempfile::tempdir().unwrap();
281        let path = write(dir.path(), "gone.rhai", GOOD);
282        let l = layer(dir.path().to_path_buf());
283        assert!(l.get_or_load("gone").is_some());
284        std::fs::remove_file(&path).unwrap();
285        assert!(l.get_or_load("gone").is_none());
286    }
287
288    #[test]
289    fn broken_script_not_resolved() {
290        let dir = tempfile::tempdir().unwrap();
291        write(dir.path(), "bad.rhai", "fn inference( { oops");
292        let l = layer(dir.path().to_path_buf());
293        assert!(l.get_or_load("bad").is_none());
294        // Not cached, so a fixed file loads on the next reference.
295        let path = write(dir.path(), "bad.rhai", GOOD);
296        bump_mtime(&path);
297        assert!(l.get_or_load("bad").is_some());
298    }
299
300    #[test]
301    fn resolve_path_honors_overrides() {
302        let dir = tempfile::tempdir().unwrap();
303        let abs = dir.path().join("elsewhere.rhai");
304        std::fs::write(&abs, GOOD).unwrap();
305        let mut overrides = HashMap::new();
306        // stem override
307        overrides.insert(
308            "a".to_string(),
309            ScriptProviderSpec {
310                script: Some("custom".to_string()),
311                ..Default::default()
312            },
313        );
314        // ".rhai" suffix override
315        overrides.insert(
316            "b".to_string(),
317            ScriptProviderSpec {
318                script: Some("custom.rhai".to_string()),
319                ..Default::default()
320            },
321        );
322        // absolute-path override
323        overrides.insert(
324            "c".to_string(),
325            ScriptProviderSpec {
326                script: Some(abs.to_string_lossy().into_owned()),
327                ..Default::default()
328            },
329        );
330        let l = ScriptProviderLayer::new(
331            dir.path().to_path_buf(),
332            overrides,
333            HashMap::new(),
334            None,
335            Vec::new(),
336        );
337        assert_eq!(l.resolve_path("a"), Some(dir.path().join("custom.rhai")));
338        assert_eq!(l.resolve_path("b"), Some(dir.path().join("custom.rhai")));
339        assert_eq!(l.resolve_path("c"), Some(abs));
340        assert_eq!(l.resolve_path("z"), Some(dir.path().join("z.rhai")));
341    }
342
343    /// A relative `script` override may not climb out of the providers
344    /// directory. Whatever it reached would be compiled and run as a provider -
345    /// the one script surface with no permission layer in front of it - so the
346    /// traversal is refused outright rather than normalized away.
347    #[test]
348    fn relative_script_override_cannot_escape_the_providers_dir() {
349        let dir = tempfile::tempdir().unwrap();
350        let mut overrides = HashMap::new();
351        for (name, script) in [
352            ("a", "../../tools/evil"),
353            ("b", "../evil.rhai"),
354            ("c", "sub/../../evil"),
355        ] {
356            overrides.insert(
357                name.to_string(),
358                ScriptProviderSpec {
359                    script: Some(script.to_string()),
360                    ..Default::default()
361                },
362            );
363        }
364        let l = ScriptProviderLayer::new(
365            dir.path().to_path_buf(),
366            overrides,
367            HashMap::new(),
368            None,
369            Vec::new(),
370        );
371        for name in ["a", "b", "c"] {
372            assert_eq!(l.resolve_path(name), None, "{name} should be refused");
373            assert!(l.get_or_load(name).is_none(), "{name} must not load");
374        }
375    }
376
377    /// A nested path *inside* the directory is still fine - only `..` is refused.
378    #[test]
379    fn nested_relative_override_inside_the_dir_still_resolves() {
380        let dir = tempfile::tempdir().unwrap();
381        let mut overrides = HashMap::new();
382        overrides.insert(
383            "a".to_string(),
384            ScriptProviderSpec {
385                script: Some("vendor/custom".to_string()),
386                ..Default::default()
387            },
388        );
389        let l = ScriptProviderLayer::new(
390            dir.path().to_path_buf(),
391            overrides,
392            HashMap::new(),
393            None,
394            Vec::new(),
395        );
396        assert_eq!(
397            l.resolve_path("a"),
398            Some(dir.path().join("vendor/custom.rhai"))
399        );
400    }
401
402    #[test]
403    fn init_config_reaches_the_script() {
404        let dir = tempfile::tempdir().unwrap();
405        // Script echoes config.base_url into state; inference returns it.
406        write(
407            dir.path(),
408            "echo.rhai",
409            "fn initialize(config) { #{ b: config.base_url } }\n\
410             fn inference(state, request) { #{ content: state.b } }",
411        );
412        let mut overrides = HashMap::new();
413        overrides.insert(
414            "echo".to_string(),
415            ScriptProviderSpec {
416                init_config: serde_json::json!({ "base_url": "http://cfg" }),
417                ..Default::default()
418            },
419        );
420        let l = ScriptProviderLayer::new(
421            dir.path().to_path_buf(),
422            overrides,
423            HashMap::new(),
424            None,
425            Vec::new(),
426        );
427        assert!(l.get_or_load("echo").is_some());
428    }
429}