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