Skip to main content

nexo_core/agent/
plugin_config_loader.rs

1//! Plugin-scoped config dir loader.
2//!
3//! Reads each plugin's `<config_dir>/plugins/<plugin_id>/*.yaml`
4//! files, deep-merges them with `${ENV_VAR}` resolution, validates
5//! the merged tree against `manifest.config.schema_path`'s
6//! JSONSchema (when present), and surfaces the result as
7//! [`PluginConfig`] for the init-loop to thread into
8//! `PluginInitContext`.
9//!
10//! Multi-file sharding lets operators split sensitive settings
11//! (credentials) from declarative ones (channel allowlists).
12//! Files merge alphabetically:
13//!
14//! ```text
15//! <config_dir>/plugins/slack/
16//!   01-credentials.yaml
17//!   02-channels.yaml
18//!   03-allowlist.yaml
19//! ```
20//!
21//! Empty / missing dir is OK — returns an empty mapping. Plugins
22//! whose schema declares all fields optional load with no
23//! operator action.
24//!
25//! Schema validation reuses the lightweight subset validator
26//! shipped in `nexo-plugin-manifest::config_schema` (covers
27//! `type`, `required`, `properties`, `additionalProperties`,
28//! `enum`). Plugins needing `oneOf` / `$ref` / `pattern` get
29//! richer validation in 81.4.c.
30//!
31//! Hot-reload of plugin config files is OUT OF SCOPE — operators
32//! restart the daemon today. 81.4.b wires the post-hook.
33
34use std::collections::BTreeSet;
35use std::path::{Path, PathBuf};
36
37use nexo_plugin_manifest::{validate_config, ConfigSchemaError, PluginManifest};
38use serde_yaml::Value;
39
40/// Pre-loaded + pre-validated plugin config. Empty mapping when
41/// the operator has not placed any yaml files at the per-plugin
42/// dir — the plugin still boots; its `init` decides whether the
43/// missing config is acceptable.
44#[derive(Debug, Clone)]
45pub struct PluginConfig {
46    /// Deep-merged YAML value across every `*.yaml` / `*.yml`
47    /// file in the plugin's config dir. Always at least
48    /// `Value::Mapping(Mapping::new())` (never `Null`).
49    pub merged: Value,
50    /// `true` when `manifest.config.schema_path` was set AND the
51    /// merged value validated cleanly against it.
52    pub schema_validated: bool,
53    /// Absolute paths of every yaml file consumed, in merge
54    /// order. Empty when the dir was missing or contained no
55    /// yaml files.
56    pub source_files: Vec<PathBuf>,
57}
58
59impl PluginConfig {
60    fn empty() -> Self {
61        Self {
62            merged: Value::Mapping(serde_yaml::Mapping::new()),
63            schema_validated: false,
64            source_files: Vec::new(),
65        }
66    }
67}
68
69/// Errors `load_plugin_config` can return. Boundary type — every
70/// failure mode the init-loop needs to discriminate is its own
71/// variant.
72#[derive(Debug, thiserror::Error)]
73pub enum PluginConfigError {
74    #[error("read dir {path}: {error}", path = .path.display())]
75    DirRead { path: PathBuf, error: String },
76    #[error("read file {path}: {error}", path = .path.display())]
77    FileRead { path: PathBuf, error: String },
78    #[error("env-var resolve in {path}: {error}", path = .path.display())]
79    EnvResolve { path: PathBuf, error: String },
80    #[error("yaml parse {path}: {error}", path = .path.display())]
81    YamlParse { path: PathBuf, error: String },
82    #[error("read schema {path}: {error}", path = .path.display())]
83    SchemaRead { path: PathBuf, error: String },
84    #[error("parse schema {path}: {error}", path = .path.display())]
85    SchemaParse { path: PathBuf, error: String },
86    #[error(
87        "schema validation failed against {path} ({} error(s))", errors.len(),
88        path = .schema_path.display()
89    )]
90    SchemaValidation {
91        schema_path: PathBuf,
92        errors: Vec<ConfigSchemaError>,
93    },
94    #[error("symlink at {path} escapes the plugin config dir", path = .path.display())]
95    SymlinkEscape { path: PathBuf },
96}
97
98/// Stable string discriminator for the JSON shape used by the
99/// `plugin.lifecycle.<id>.config_load_failed` broker event +
100/// future doctor surface.
101pub fn config_error_kind(e: &PluginConfigError) -> &'static str {
102    match e {
103        PluginConfigError::DirRead { .. } => "DirRead",
104        PluginConfigError::FileRead { .. } => "FileRead",
105        PluginConfigError::EnvResolve { .. } => "EnvResolve",
106        PluginConfigError::YamlParse { .. } => "YamlParse",
107        PluginConfigError::SchemaRead { .. } => "SchemaRead",
108        PluginConfigError::SchemaParse { .. } => "SchemaParse",
109        PluginConfigError::SchemaValidation { .. } => "SchemaValidation",
110        PluginConfigError::SymlinkEscape { .. } => "SymlinkEscape",
111    }
112}
113
114/// `<config_dir>/plugins/<plugin_id>/`. Convenience for callers
115/// that need the path before invoking the loader.
116pub fn plugin_config_dir_for(config_dir: &Path, plugin_id: &str) -> PathBuf {
117    config_dir.join("plugins").join(plugin_id)
118}
119
120/// Load + validate a single plugin's config. Reads
121/// `<config_dir>/plugins/<plugin_id>/*.yaml` (alphabetical),
122/// resolves `${ENV_VAR}` placeholders, deep-merges, and
123/// validates against `manifest.config.schema_path` (resolved
124/// relative to `plugin_root`) when set.
125pub fn load_plugin_config(
126    plugin_root: &Path,
127    config_dir: &Path,
128    manifest: &PluginManifest,
129) -> Result<PluginConfig, PluginConfigError> {
130    let plugin_id = &manifest.plugin.id;
131    let dir = plugin_config_dir_for(config_dir, plugin_id);
132
133    if !dir.exists() {
134        return Ok(PluginConfig::empty());
135    }
136
137    let canonical_dir = std::fs::canonicalize(&dir).map_err(|e| PluginConfigError::DirRead {
138        path: dir.clone(),
139        error: e.to_string(),
140    })?;
141
142    let entries = std::fs::read_dir(&canonical_dir).map_err(|e| PluginConfigError::DirRead {
143        path: canonical_dir.clone(),
144        error: e.to_string(),
145    })?;
146
147    // Filter to files with .yaml/.yml extensions, then sort
148    // alphabetically by file name for deterministic merge order.
149    let mut yaml_files: BTreeSet<PathBuf> = BTreeSet::new();
150    for entry in entries.flatten() {
151        let path = entry.path();
152        let ft = match entry.file_type() {
153            Ok(ft) => ft,
154            Err(_) => continue,
155        };
156        if !ft.is_file() && !ft.is_symlink() {
157            continue;
158        }
159        let ext = path
160            .extension()
161            .and_then(|s| s.to_str())
162            .map(|s| s.to_ascii_lowercase());
163        if !matches!(ext.as_deref(), Some("yaml") | Some("yml")) {
164            continue;
165        }
166        yaml_files.insert(path);
167    }
168
169    let mut merged = Value::Mapping(serde_yaml::Mapping::new());
170    let mut source_files = Vec::with_capacity(yaml_files.len());
171
172    for path in &yaml_files {
173        // Path-traversal guard — canonicalize + starts_with the
174        // canonicalized dir. Symlinks pointing INTO the dir are
175        // fine; escapes rejected.
176        let canonical = std::fs::canonicalize(path).map_err(|e| PluginConfigError::FileRead {
177            path: path.clone(),
178            error: e.to_string(),
179        })?;
180        if !canonical.starts_with(&canonical_dir) {
181            return Err(PluginConfigError::SymlinkEscape { path: path.clone() });
182        }
183
184        let raw = std::fs::read_to_string(&canonical).map_err(|e| PluginConfigError::FileRead {
185            path: canonical.clone(),
186            error: e.to_string(),
187        })?;
188
189        let resolved =
190            nexo_config::env::resolve_placeholders(&raw, &canonical.display().to_string())
191                .map_err(|e| PluginConfigError::EnvResolve {
192                    path: canonical.clone(),
193                    error: e.to_string(),
194                })?;
195
196        let value: Value =
197            serde_yaml::from_str(&resolved).map_err(|e| PluginConfigError::YamlParse {
198                path: canonical.clone(),
199                error: e.to_string(),
200            })?;
201
202        // `serde_yaml::from_str("")` returns `Value::Null`; treat
203        // empty file as empty mapping for merge purposes.
204        let value = if matches!(value, Value::Null) {
205            Value::Mapping(serde_yaml::Mapping::new())
206        } else {
207            value
208        };
209
210        merge_yaml(&mut merged, value);
211        source_files.push(canonical);
212    }
213
214    let schema_validated =
215        match manifest.plugin.config.schema_path.as_deref() {
216            None => false,
217            Some(rel) => {
218                let schema_path = plugin_root.join(rel);
219                let schema_bytes =
220                    std::fs::read(&schema_path).map_err(|e| PluginConfigError::SchemaRead {
221                        path: schema_path.clone(),
222                        error: e.to_string(),
223                    })?;
224                let schema_json: serde_json::Value = serde_json::from_slice(&schema_bytes)
225                    .map_err(|e| PluginConfigError::SchemaParse {
226                        path: schema_path.clone(),
227                        error: e.to_string(),
228                    })?;
229                // Round-trip via JSON string to convert
230                // `serde_yaml::Value` into `serde_json::Value`. Direct
231                // `serde_json::to_value(yaml_val)` works for the
232                // subset we need (mappings + scalars + sequences).
233                let merged_json: serde_json::Value =
234                    yaml_to_json(&merged).map_err(|e| PluginConfigError::YamlParse {
235                        path: schema_path.clone(),
236                        error: format!("merged yaml -> json: {e}"),
237                    })?;
238                let errors = validate_config(&merged_json, &schema_json);
239                if !errors.is_empty() {
240                    return Err(PluginConfigError::SchemaValidation {
241                        schema_path,
242                        errors,
243                    });
244                }
245                true
246            }
247        };
248
249    Ok(PluginConfig {
250        merged,
251        schema_validated,
252        source_files,
253    })
254}
255
256/// Deep-merge `from` into `into`. Mappings merge key-by-key
257/// recursively; arrays full-replace (NOT concat); scalars / null
258/// from `from` overwrite `into`.
259fn merge_yaml(into: &mut Value, from: Value) {
260    match (into, from) {
261        (Value::Mapping(into_map), Value::Mapping(from_map)) => {
262            for (k, v) in from_map {
263                if let Some(existing) = into_map.get_mut(&k) {
264                    merge_yaml(existing, v);
265                } else {
266                    into_map.insert(k, v);
267                }
268            }
269        }
270        (slot, from) => {
271            *slot = from;
272        }
273    }
274}
275
276/// Convert `serde_yaml::Value` to `serde_json::Value` via
277/// JSON-string round-trip. Lossy on YAML-specific features
278/// (tags, aliases) but fine for the subset our schema validator
279/// reads.
280fn yaml_to_json(value: &Value) -> Result<serde_json::Value, String> {
281    let s = serde_yaml::to_string(value).map_err(|e| e.to_string())?;
282    let json: serde_json::Value =
283        serde_yaml::from_str(&s).map_err(|e| format!("yaml->json reparse: {e}"))?;
284    Ok(json)
285}
286
287// ── Tests ────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use nexo_plugin_manifest::PluginManifest;
293    use std::fs;
294    use tempfile::tempdir;
295
296    fn manifest(plugin_id: &str, schema_path: Option<&str>) -> PluginManifest {
297        let mut raw = format!(
298            "[plugin]\n\
299             id = \"{plugin_id}\"\n\
300             version = \"0.1.0\"\n\
301             name = \"{plugin_id}\"\n\
302             description = \"fixture\"\n\
303             min_nexo_version = \">=0.0.1\"\n",
304        );
305        if let Some(p) = schema_path {
306            raw.push_str(&format!("\n[plugin.config]\nschema_path = \"{p}\"\n"));
307        }
308        toml::from_str(&raw).unwrap()
309    }
310
311    fn write_file(dir: &Path, name: &str, body: &str) {
312        fs::create_dir_all(dir).unwrap();
313        fs::write(dir.join(name), body).unwrap();
314    }
315
316    #[test]
317    fn load_returns_empty_when_dir_missing() {
318        let cfg_dir = tempdir().unwrap();
319        let plugin_root = tempdir().unwrap();
320        let m = manifest("slack", None);
321        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
322        assert!(matches!(pc.merged, Value::Mapping(ref m) if m.is_empty()));
323        assert!(!pc.schema_validated);
324        assert!(pc.source_files.is_empty());
325    }
326
327    #[test]
328    fn load_returns_empty_when_dir_empty() {
329        let cfg_dir = tempdir().unwrap();
330        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
331        fs::create_dir_all(&plugin_dir).unwrap();
332        let plugin_root = tempdir().unwrap();
333        let m = manifest("slack", None);
334        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
335        assert!(matches!(pc.merged, Value::Mapping(ref m) if m.is_empty()));
336        assert!(pc.source_files.is_empty());
337    }
338
339    #[test]
340    fn load_skips_non_yaml_files() {
341        let cfg_dir = tempdir().unwrap();
342        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
343        write_file(&plugin_dir, "notes.txt", "ignore me");
344        write_file(&plugin_dir, "01-cred.yaml", "api_token: abc\n");
345        let plugin_root = tempdir().unwrap();
346        let m = manifest("slack", None);
347        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
348        assert_eq!(pc.source_files.len(), 1);
349        assert!(pc.source_files[0].ends_with("01-cred.yaml"));
350        assert_eq!(
351            pc.merged.get("api_token").and_then(Value::as_str),
352            Some("abc")
353        );
354    }
355
356    #[test]
357    fn load_merges_files_alphabetically_with_deep_merge() {
358        let cfg_dir = tempdir().unwrap();
359        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
360        write_file(&plugin_dir, "01-cred.yaml", "auth:\n  token: t1\n");
361        write_file(&plugin_dir, "02-extra.yaml", "auth:\n  workspace: w1\n");
362        let plugin_root = tempdir().unwrap();
363        let m = manifest("slack", None);
364        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
365        assert_eq!(pc.source_files.len(), 2);
366        let auth = pc.merged.get("auth").unwrap();
367        assert_eq!(auth.get("token").and_then(Value::as_str), Some("t1"));
368        assert_eq!(auth.get("workspace").and_then(Value::as_str), Some("w1"));
369    }
370
371    #[test]
372    fn load_later_files_overwrite_scalar_keys() {
373        let cfg_dir = tempdir().unwrap();
374        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
375        write_file(&plugin_dir, "01-base.yaml", "endpoint: prod\n");
376        write_file(&plugin_dir, "02-override.yaml", "endpoint: staging\n");
377        let plugin_root = tempdir().unwrap();
378        let m = manifest("slack", None);
379        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
380        assert_eq!(
381            pc.merged.get("endpoint").and_then(Value::as_str),
382            Some("staging")
383        );
384    }
385
386    #[test]
387    fn load_arrays_are_full_replace_not_concat() {
388        let cfg_dir = tempdir().unwrap();
389        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
390        write_file(&plugin_dir, "01-base.yaml", "channels:\n  - a\n  - b\n");
391        write_file(&plugin_dir, "02-override.yaml", "channels:\n  - x\n");
392        let plugin_root = tempdir().unwrap();
393        let m = manifest("slack", None);
394        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
395        let channels = pc
396            .merged
397            .get("channels")
398            .and_then(Value::as_sequence)
399            .unwrap();
400        assert_eq!(channels.len(), 1);
401        assert_eq!(channels[0].as_str(), Some("x"));
402    }
403
404    #[test]
405    fn load_resolves_env_var_substitutions() {
406        // Use a unique env var to avoid pollution.
407        std::env::set_var("NEXO_TEST_PCL_TOKEN_X", "secret-value");
408        let cfg_dir = tempdir().unwrap();
409        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
410        write_file(
411            &plugin_dir,
412            "01-cred.yaml",
413            "api_token: \"${NEXO_TEST_PCL_TOKEN_X}\"\n",
414        );
415        let plugin_root = tempdir().unwrap();
416        let m = manifest("slack", None);
417        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
418        assert_eq!(
419            pc.merged.get("api_token").and_then(Value::as_str),
420            Some("secret-value")
421        );
422        std::env::remove_var("NEXO_TEST_PCL_TOKEN_X");
423    }
424
425    #[test]
426    fn load_returns_yaml_parse_error_on_malformed_file() {
427        let cfg_dir = tempdir().unwrap();
428        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
429        write_file(&plugin_dir, "01-bad.yaml", "key: [unclosed\n");
430        let plugin_root = tempdir().unwrap();
431        let m = manifest("slack", None);
432        let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
433        assert!(matches!(err, PluginConfigError::YamlParse { .. }));
434    }
435
436    #[test]
437    fn load_validates_against_schema_when_present() {
438        let cfg_dir = tempdir().unwrap();
439        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
440        write_file(
441            &plugin_dir,
442            "01-cred.yaml",
443            "api_token: abc\nworkspace: ws\n",
444        );
445        let plugin_root = tempdir().unwrap();
446        fs::write(
447            plugin_root.path().join("config.schema.json"),
448            r#"{
449                "type": "object",
450                "required": ["api_token"],
451                "properties": {
452                    "api_token": { "type": "string" },
453                    "workspace": { "type": "string" }
454                }
455            }"#,
456        )
457        .unwrap();
458        let m = manifest("slack", Some("config.schema.json"));
459        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
460        assert!(pc.schema_validated);
461    }
462
463    #[test]
464    fn load_returns_schema_validation_errors_with_pointers() {
465        let cfg_dir = tempdir().unwrap();
466        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
467        // Missing required `api_token`.
468        write_file(&plugin_dir, "01-cred.yaml", "workspace: ws\n");
469        let plugin_root = tempdir().unwrap();
470        fs::write(
471            plugin_root.path().join("config.schema.json"),
472            r#"{
473                "type": "object",
474                "required": ["api_token"],
475                "properties": {
476                    "api_token": { "type": "string" }
477                }
478            }"#,
479        )
480        .unwrap();
481        let m = manifest("slack", Some("config.schema.json"));
482        let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
483        match err {
484            PluginConfigError::SchemaValidation { errors, .. } => {
485                assert!(!errors.is_empty());
486                // Subset validator surfaces required-field failures
487                // with a JSON pointer.
488                assert!(errors
489                    .iter()
490                    .any(|e| e.message.contains("required") || e.message.contains("api_token")));
491            }
492            other => panic!("expected SchemaValidation, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn load_skips_validation_when_schema_path_unset() {
498        let cfg_dir = tempdir().unwrap();
499        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
500        write_file(&plugin_dir, "01-cred.yaml", "anything_goes: 42\n");
501        let plugin_root = tempdir().unwrap();
502        let m = manifest("slack", None);
503        let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
504        assert!(!pc.schema_validated);
505    }
506
507    #[test]
508    #[cfg(unix)]
509    fn load_rejects_symlink_escape() {
510        let cfg_dir = tempdir().unwrap();
511        let plugin_dir = cfg_dir.path().join("plugins").join("slack");
512        fs::create_dir_all(&plugin_dir).unwrap();
513        // Create an outside-target file the symlink will point at.
514        let outside = cfg_dir.path().join("outside.yaml");
515        fs::write(&outside, "evil: true\n").unwrap();
516        std::os::unix::fs::symlink(&outside, plugin_dir.join("01-evil.yaml")).unwrap();
517        let plugin_root = tempdir().unwrap();
518        let m = manifest("slack", None);
519        let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
520        assert!(matches!(err, PluginConfigError::SymlinkEscape { .. }));
521    }
522
523    #[test]
524    fn config_error_kind_maps_all_variants() {
525        let path = PathBuf::from("/x");
526        let cases: Vec<(PluginConfigError, &'static str)> = vec![
527            (
528                PluginConfigError::DirRead {
529                    path: path.clone(),
530                    error: "x".into(),
531                },
532                "DirRead",
533            ),
534            (
535                PluginConfigError::FileRead {
536                    path: path.clone(),
537                    error: "x".into(),
538                },
539                "FileRead",
540            ),
541            (
542                PluginConfigError::EnvResolve {
543                    path: path.clone(),
544                    error: "x".into(),
545                },
546                "EnvResolve",
547            ),
548            (
549                PluginConfigError::YamlParse {
550                    path: path.clone(),
551                    error: "x".into(),
552                },
553                "YamlParse",
554            ),
555            (
556                PluginConfigError::SchemaRead {
557                    path: path.clone(),
558                    error: "x".into(),
559                },
560                "SchemaRead",
561            ),
562            (
563                PluginConfigError::SchemaParse {
564                    path: path.clone(),
565                    error: "x".into(),
566                },
567                "SchemaParse",
568            ),
569            (
570                PluginConfigError::SchemaValidation {
571                    schema_path: path.clone(),
572                    errors: vec![],
573                },
574                "SchemaValidation",
575            ),
576            (
577                PluginConfigError::SymlinkEscape { path: path.clone() },
578                "SymlinkEscape",
579            ),
580        ];
581        for (e, want) in cases {
582            assert_eq!(config_error_kind(&e), want);
583        }
584    }
585}