Skip to main content

mlua_swarm_compile/
linker.rs

1//! Blueprint loader (Phase B). Loads a Blueprint from a JSON / YAML file
2//! and recursively expands the internal `{"$file": "..."}` refs.
3//!
4//! ## File-ref expansion
5//!
6//! Anywhere inside the JSON value, this form is replaced by the referenced
7//! file's contents **as a raw string**. Paths are resolved **relative to
8//! the Blueprint file's directory**:
9//!
10//! ```jsonc
11//! { "$file": "prompts/system-writer.md" }
12//! ```
13//!
14//! Typical uses:
15//!
16//! - Externalising a large prompt out of a flow `Step.in`:
17//!   `{"op":"lit","value":{"$file":"prompts/x.md"}}`.
18//! - Externalising any field inside `AgentDef.spec` (system_prompt, args,
19//!   etc.).
20//! - Externalising per-agent or global `hints`.
21//!
22//! ## Agent-md ref expansion (structured ref)
23//!
24//! Specialised ref that expands an `agent.md` (frontmatter + body) into
25//! an **`AgentDef` object**:
26//!
27//! ```jsonc
28//! {
29//!   "agents": [
30//!     { "$agent_md": "agents/researcher.md" }
31//!   ]
32//! }
33//! ```
34//!
35//! Where `$file` returns a raw string, `$agent_md` runs the file through
36//! `agent_md_loader::parse` and returns a fully-populated `AgentDef` JSON
37//! object with `profile.system_prompt`, `meta`, `spec`, and so on already
38//! filled in. Path hygiene matches `$file`: absolute paths and `..` are
39//! rejected.
40
41use mlua_swarm_schema::{default_global_agent_kind, AgentKind, Blueprint};
42use serde_json::Value;
43use std::path::{Path, PathBuf};
44use thiserror::Error;
45
46/// Resolution config for `$agent_md` / `$file` refs. Ordered list of
47/// directories the linker walks first-hit-wins across 6 tiers.
48///
49/// Tier order (highest priority first):
50///
51/// 1. `base` — bp.lua parent directory (always tier 1).
52/// 2. `in_bp_includes` — in-bp declared `blueprint_ref_includes`
53///    (relative to `base`).
54/// 3. `env_includes` — env `MSE_BLUEPRINT_INCLUDES` (`:`- or `;`-
55///    separated absolute paths).
56/// 4. `cli_includes` — CLI `--include <path>` repeatable.
57/// 5. `config_includes` — server / config-file `blueprint_ref_includes`.
58/// 6. `bundled_default` — bundled fallback (typically
59///    `crates/mlua-swarm-cli/src/mcp/resources/samples/agents/`).
60///
61/// Callers construct the config via [`ResolveConfig::new`] and layer
62/// additional tiers through the builder methods, then pass the config
63/// to [`expand_file_refs_with_config`].
64#[derive(Debug, Clone, Default)]
65pub struct ResolveConfig {
66    /// bp.lua parent directory (always tier 1, always first).
67    pub base: PathBuf,
68    /// In-bp declared includes (tier 2, relative to `base`).
69    pub in_bp_includes: Vec<PathBuf>,
70    /// Env `MSE_BLUEPRINT_INCLUDES` (tier 3).
71    pub env_includes: Vec<PathBuf>,
72    /// CLI `--include <path>` repeatable (tier 4).
73    pub cli_includes: Vec<PathBuf>,
74    /// Server / config-file `blueprint_ref_includes` (tier 5).
75    pub config_includes: Vec<PathBuf>,
76    /// Bundled default (tier 6). `None` = no bundled fallback (typical
77    /// for server-side use, where the server never ships authoring
78    /// files).
79    pub bundled_default: Option<PathBuf>,
80}
81
82impl ResolveConfig {
83    /// Build a config with only the base tier set. Callers layer the
84    /// remaining tiers via builder methods.
85    pub fn new(base: impl Into<PathBuf>) -> Self {
86        Self {
87            base: base.into(),
88            ..Default::default()
89        }
90    }
91
92    /// Set the in-bp include list (tier 2). Paths are resolved relative
93    /// to `base` at search time.
94    pub fn with_in_bp_includes(mut self, v: Vec<PathBuf>) -> Self {
95        self.in_bp_includes = v;
96        self
97    }
98
99    /// Set the env include list (tier 3).
100    pub fn with_env_includes(mut self, v: Vec<PathBuf>) -> Self {
101        self.env_includes = v;
102        self
103    }
104
105    /// Set the CLI include list (tier 4).
106    pub fn with_cli_includes(mut self, v: Vec<PathBuf>) -> Self {
107        self.cli_includes = v;
108        self
109    }
110
111    /// Set the config-file include list (tier 5).
112    pub fn with_config_includes(mut self, v: Vec<PathBuf>) -> Self {
113        self.config_includes = v;
114        self
115    }
116
117    /// Set the bundled-default fallback (tier 6). Pass `None` to
118    /// disable the fallback (server-side default).
119    pub fn with_bundled_default(mut self, p: Option<PathBuf>) -> Self {
120        self.bundled_default = p;
121        self
122    }
123
124    /// Iterate every configured directory in cascade order (tier 1 → 6).
125    /// `in_bp_includes` entries are joined onto `base` at iteration
126    /// time so callers can pass in relative paths as declared in the
127    /// bp.lua source.
128    pub fn search_paths(&self) -> impl Iterator<Item = PathBuf> + '_ {
129        let base = self.base.clone();
130        std::iter::once(self.base.clone())
131            .chain(self.in_bp_includes.iter().map(move |p| base.join(p)))
132            .chain(self.env_includes.iter().cloned())
133            .chain(self.cli_includes.iter().cloned())
134            .chain(self.config_includes.iter().cloned())
135            .chain(self.bundled_default.iter().cloned())
136    }
137}
138
139/// Read `MSE_BLUEPRINT_INCLUDES` and split it into a directory list
140/// using the platform-native separator (`:` on Unix, `;` on Windows).
141/// Returns an empty vec when the variable is unset.
142pub fn env_blueprint_includes() -> Vec<PathBuf> {
143    std::env::var_os("MSE_BLUEPRINT_INCLUDES")
144        .map(|s| std::env::split_paths(&s).collect())
145        .unwrap_or_default()
146}
147
148/// Pull the top-level `blueprint_ref_includes` list out of the raw BP
149/// JSON. Returns an empty vec when the field is absent or malformed.
150/// Consumed by the CLI / server as tier 2 of the cascade.
151pub fn pre_read_in_bp_includes(val: &Value) -> Vec<PathBuf> {
152    val.get("blueprint_ref_includes")
153        .and_then(|v| v.as_array())
154        .map(|arr| {
155            arr.iter()
156                .filter_map(|v| v.as_str())
157                .map(PathBuf::from)
158                .collect()
159        })
160        .unwrap_or_default()
161}
162
163/// Everything that can go wrong while loading and `$file`/`$agent_md`
164/// expanding a Blueprint from disk.
165#[derive(Debug, Error)]
166pub enum LoadError {
167    /// Reading the Blueprint file (or a referenced `$file`/`$agent_md`)
168    /// failed.
169    #[error("io: {0}")]
170    Io(#[from] std::io::Error),
171    /// The `.json` file did not parse as JSON.
172    #[error("json parse: {0}")]
173    Json(#[from] serde_json::Error),
174    /// The `.yaml`/`.yml` file did not parse as YAML.
175    #[error("yaml parse: {0}")]
176    Yaml(#[from] serde_yaml::Error),
177    /// The file extension is not one of `.json` / `.yaml` / `.yml`.
178    #[error("unsupported extension: {0:?} (expected .json / .yaml / .yml)")]
179    UnknownFormat(Option<String>),
180    /// A `$file`/`$agent_md` ref failed path hygiene checks or the
181    /// referenced file could not be read/parsed.
182    #[error("$file ref expansion at {path:?}: {msg}")]
183    FileRef {
184        /// The resolved (or rejected) path of the ref.
185        path: PathBuf,
186        /// Human-readable description of what went wrong.
187        msg: String,
188    },
189    /// The expanded JSON value did not deserialize into a `Blueprint`.
190    #[error("blueprint shape invalid: {0}")]
191    Shape(String),
192}
193
194/// Load a Blueprint from a file path. Detects JSON vs. YAML by
195/// extension, recursively expands `$file` refs, and parses the result
196/// into a typed `Blueprint`.
197pub fn load_blueprint_from_path<P: AsRef<Path>>(path: P) -> Result<Blueprint, LoadError> {
198    let path = path.as_ref();
199    let raw = std::fs::read_to_string(path)?;
200    let ext = path
201        .extension()
202        .and_then(|e| e.to_str())
203        .map(|s| s.to_lowercase());
204    let value: Value = match ext.as_deref() {
205        Some("json") => serde_json::from_str(&raw)?,
206        Some("yaml") | Some("yml") => {
207            let yv: serde_yaml::Value = serde_yaml::from_str(&raw)?;
208            serde_json::to_value(yv)
209                .map_err(|e| LoadError::Shape(format!("yaml→json convert: {e}")))?
210        }
211        other => return Err(LoadError::UnknownFormat(other.map(|s| s.to_string()))),
212    };
213    let base = path
214        .parent()
215        .unwrap_or_else(|| Path::new("."))
216        .to_path_buf();
217    // Steps (1) and (3) of the four-layer cascade: pre-read the BP JSON's
218    // top-level `default_agent_kind`. If it is absent, fall back to the
219    // schema's `Default` impl (`Operator`). The value is passed into
220    // `expand_file_refs` and used as the loader-side kind default when a
221    // `$agent_md` has no sibling override. Step (2), the caller-side
222    // (CLI) override, is out of this function's scope — an upper layer
223    // (the server seed handler) is responsible for overwriting the
224    // pre-read value with the CLI value.
225    let default_kind = pre_read_default_agent_kind(&value);
226    let resolved = expand_file_refs(value, &base, default_kind)?;
227    let bp: Blueprint = serde_json::from_value(resolved)
228        .map_err(|e| LoadError::Shape(format!("typed parse: {e}")))?;
229    Ok(bp)
230}
231
232/// Pull `default_agent_kind` out of the raw BP JSON top level. Falls
233/// back to the schema's `Default` impl (`Operator`) if the key is
234/// missing or its type does not match. This is the first stage of
235/// resolving the default kind used inside `expand_file_refs` when a
236/// `$agent_md` has no sibling `kind` override.
237pub fn pre_read_default_agent_kind(val: &Value) -> AgentKind {
238    val.get("default_agent_kind")
239        .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
240        .unwrap_or_else(default_global_agent_kind)
241}
242
243/// Takes a JSON value: an object whose only key is `"$file": "path"` is
244/// replaced with the referenced file's contents; other objects / arrays
245/// recurse; scalars pass through unchanged.
246///
247/// Path hygiene: absolute paths and `..` parent-directory escapes are
248/// **rejected**, sandboxing all refs to the Blueprint's base-directory
249/// subtree. That structurally prevents accidentally pulling in
250/// `/etc/passwd` or `~/.ssh/id_rsa`. The trust boundary is spelled out
251/// explicitly.
252///
253/// Shared path hygiene for `$file` and `$agent_md`: absolute paths and
254/// `..` parent escapes are rejected; refs are searched across the
255/// 6-tier cascade in `cfg` (first-hit-wins). On miss, the error names
256/// every tier dir searched so authors can diagnose which include layer
257/// to add.
258fn resolve_ref_path(rel: &str, cfg: &ResolveConfig) -> Result<PathBuf, LoadError> {
259    let rel_path = Path::new(rel);
260    if rel_path.is_absolute() {
261        return Err(LoadError::FileRef {
262            path: rel_path.to_path_buf(),
263            msg: "absolute path not allowed (must be relative to Blueprint dir)".into(),
264        });
265    }
266    if rel_path
267        .components()
268        .any(|c| matches!(c, std::path::Component::ParentDir))
269    {
270        return Err(LoadError::FileRef {
271            path: rel_path.to_path_buf(),
272            msg: "'..' parent-dir escape not allowed".into(),
273        });
274    }
275    let mut searched: Vec<PathBuf> = Vec::new();
276    for dir in cfg.search_paths() {
277        let candidate = dir.join(rel_path);
278        if candidate.exists() {
279            return Ok(candidate);
280        }
281        searched.push(dir);
282    }
283    let searched_str = searched
284        .iter()
285        .map(|p| p.display().to_string())
286        .collect::<Vec<_>>()
287        .join(", ");
288    Err(LoadError::FileRef {
289        path: rel_path.to_path_buf(),
290        msg: format!("not found in include cascade (searched: {searched_str})"),
291    })
292}
293
294/// Primary entry — recursively expands `$file` / `$agent_md` refs
295/// across the 6-tier include cascade defined in `cfg`. Prefer this
296/// entry over [`expand_file_refs`] for any new caller that wants
297/// cascade-aware resolution.
298///
299/// `default_kind` is the fallback used when a `$agent_md` has no
300/// sibling `kind` — it should already be resolved by upper layers of
301/// the four-layer kind cascade. Callers resolve the BP top-level
302/// `default_agent_kind` and any CLI override before calling this
303/// function and pass in the literal kind.
304pub fn expand_file_refs_with_config(
305    val: Value,
306    cfg: &ResolveConfig,
307    default_kind: AgentKind,
308) -> Result<Value, LoadError> {
309    match val {
310        Value::Object(map) => {
311            // `$file`: a single-key raw-string substitution.
312            if map.len() == 1 {
313                if let Some(Value::String(rel)) = map.get("$file") {
314                    let full = resolve_ref_path(rel, cfg)?;
315                    let content =
316                        std::fs::read_to_string(&full).map_err(|e| LoadError::FileRef {
317                            path: full.clone(),
318                            msg: e.to_string(),
319                        })?;
320                    return Ok(Value::String(content));
321                }
322            }
323            // `$agent_md` accepts either a single-key object or an object
324            // with sibling keys. Sibling keys are shallow-merged onto the
325            // expanded AgentDef object, so the caller's values override
326            // whatever the AgentDef itself carried. Typical use: keep the
327            // name and profile from the agent.md but override only
328            // `spec.operator_ref` or `meta` at the call site.
329            //
330            // Kind resolution cascade: (a) if a sibling `"kind"` literal
331            // is present, use it as-is; (b) otherwise, fall back to the
332            // `default_kind` argument, which the caller already resolved
333            // upstream from BP `default_agent_kind` or the CLI default.
334            if let Some(Value::String(rel)) = map.get("$agent_md") {
335                let full = resolve_ref_path(rel, cfg)?;
336                // Peek at the sibling "kind"; fall back to `default_kind`
337                // if absent.
338                let resolved_kind = map
339                    .get("kind")
340                    .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
341                    .unwrap_or_else(|| default_kind.clone());
342                let def = crate::agent_md::load_file(&full, resolved_kind).map_err(|e| {
343                    LoadError::FileRef {
344                        path: full.clone(),
345                        msg: format!("agent_md parse: {e}"),
346                    }
347                })?;
348                let mut def_v = serde_json::to_value(&def).map_err(|e| LoadError::FileRef {
349                    path: full.clone(),
350                    msg: format!("agent_md serialize: {e}"),
351                })?;
352                if let Value::Object(def_map) = &mut def_v {
353                    for (k, v) in map {
354                        if k == "$agent_md" {
355                            continue;
356                        }
357                        // Recursively expand the sibling before applying
358                        // it as a shallow override.
359                        let expanded = expand_file_refs_with_config(v, cfg, default_kind.clone())?;
360                        def_map.insert(k, expanded);
361                    }
362                }
363                return Ok(def_v);
364            }
365            let mut new_map = serde_json::Map::with_capacity(map.len());
366            for (k, v) in map {
367                new_map.insert(
368                    k,
369                    expand_file_refs_with_config(v, cfg, default_kind.clone())?,
370                );
371            }
372            Ok(Value::Object(new_map))
373        }
374        Value::Array(arr) => {
375            let mut new_arr = Vec::with_capacity(arr.len());
376            for v in arr {
377                new_arr.push(expand_file_refs_with_config(v, cfg, default_kind.clone())?);
378            }
379            Ok(Value::Array(new_arr))
380        }
381        other => Ok(other),
382    }
383}
384
385/// Backward-compat adapter — resolves refs against a single-tier
386/// cascade (only `base`). New callers should use
387/// [`expand_file_refs_with_config`] to opt into the full cascade.
388pub fn expand_file_refs(
389    val: Value,
390    base: &Path,
391    default_kind: AgentKind,
392) -> Result<Value, LoadError> {
393    let cfg = ResolveConfig::new(base.to_path_buf());
394    expand_file_refs_with_config(val, &cfg, default_kind)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use serde_json::json;
401    use std::fs;
402    use tempfile::TempDir;
403
404    fn write_md(dir: &Path, rel: &str, content: &str) -> PathBuf {
405        let p = dir.join(rel);
406        if let Some(parent) = p.parent() {
407            fs::create_dir_all(parent).unwrap();
408        }
409        fs::write(&p, content).unwrap();
410        p
411    }
412
413    const AGENT_MD: &str = "---\n\
414name: researcher\n\
415description: focus on XX/YY sites\n\
416model: sonnet\n\
417---\n\
418You are a researcher. Focus on XX/YY sites.\n";
419
420    #[test]
421    fn agent_md_ref_expands_to_typed_agent_def_object() {
422        let dir = TempDir::new().unwrap();
423        write_md(dir.path(), "agents/r.md", AGENT_MD);
424
425        let bp = json!({
426            "agents": [ { "$agent_md": "agents/r.md" } ]
427        });
428        let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
429
430        let agent = &resolved["agents"][0];
431        assert!(agent.is_object(), "expanded value is JSON object");
432        assert_eq!(agent["name"], "researcher");
433        assert_eq!(agent["kind"], "operator", "default kind from loader");
434        assert!(
435            agent["profile"]["system_prompt"]
436                .as_str()
437                .unwrap()
438                .contains("You are a researcher"),
439            "profile.system_prompt baked from body, got: {:?}",
440            agent["profile"]
441        );
442    }
443
444    #[test]
445    fn agent_md_ref_rejects_absolute_path() {
446        let dir = TempDir::new().unwrap();
447        let bp = json!({ "$agent_md": "/etc/passwd" });
448        let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err("abs rejected");
449        assert!(format!("{err}").contains("absolute path"), "got: {err}");
450    }
451
452    #[test]
453    fn agent_md_ref_rejects_parent_dir_escape() {
454        let dir = TempDir::new().unwrap();
455        let bp = json!({ "$agent_md": "../escape.md" });
456        let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err(".. rejected");
457        assert!(format!("{err}").contains("parent-dir escape"), "got: {err}");
458    }
459
460    #[test]
461    fn agent_md_ref_merges_sibling_keys_as_shallow_override() {
462        let dir = TempDir::new().unwrap();
463        write_md(dir.path(), "agents/r.md", AGENT_MD);
464        let bp = json!({
465            "$agent_md": "agents/r.md",
466            "spec": { "operator_ref": "ws-sid-42" },
467        });
468        let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
469        assert_eq!(resolved["name"], "researcher", "name from md preserved");
470        assert_eq!(
471            resolved["spec"]["operator_ref"], "ws-sid-42",
472            "sibling spec overrides md default (= Null)"
473        );
474        assert!(
475            resolved["profile"]["system_prompt"]
476                .as_str()
477                .unwrap()
478                .contains("You are a researcher"),
479            "profile from md preserved"
480        );
481    }
482
483    #[test]
484    fn file_ref_still_returns_raw_string_unchanged() {
485        let dir = TempDir::new().unwrap();
486        write_md(dir.path(), "prompts/raw.md", "raw body content");
487        let bp = json!({ "$file": "prompts/raw.md" });
488        let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
489        assert_eq!(resolved, json!("raw body content"));
490    }
491
492    // ────────────────────────────────────────────────────────────────
493    // Include-cascade tests (Phase 3 — GH issue 4c4e3eb8)
494    // ────────────────────────────────────────────────────────────────
495
496    #[test]
497    fn cascade_falls_through_tiers() {
498        // Same filename in three distinct dirs, each with a different body;
499        // verify only the highest-priority tier hit is used.
500        let base_dir = TempDir::new().unwrap();
501        let cli_dir = TempDir::new().unwrap();
502        let bundled_dir = TempDir::new().unwrap();
503
504        // Tier 1 (base): the "correct" one.
505        write_md(base_dir.path(), "prompts/x.md", "from-base");
506        // Tier 4 (cli): shadowed by base.
507        write_md(cli_dir.path(), "prompts/x.md", "from-cli");
508        // Tier 6 (bundled): shadowed by base and cli.
509        write_md(bundled_dir.path(), "prompts/x.md", "from-bundled");
510
511        let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
512            .with_cli_includes(vec![cli_dir.path().to_path_buf()])
513            .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
514        let bp = json!({ "$file": "prompts/x.md" });
515        let resolved =
516            expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
517        assert_eq!(resolved, json!("from-base"));
518    }
519
520    #[test]
521    fn cascade_reports_all_searched_paths_on_miss() {
522        // No file exists in any tier — error message must name every
523        // searched dir so the author can diagnose the miss.
524        let base_dir = TempDir::new().unwrap();
525        let cli_a = TempDir::new().unwrap();
526        let cli_b = TempDir::new().unwrap();
527
528        let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
529            .with_cli_includes(vec![cli_a.path().to_path_buf(), cli_b.path().to_path_buf()]);
530        let bp = json!({ "$file": "prompts/missing.md" });
531        let err = expand_file_refs_with_config(bp, &cfg, AgentKind::Operator)
532            .expect_err("miss reports cascade");
533        let msg = format!("{err}");
534        assert!(
535            msg.contains(base_dir.path().to_str().unwrap()),
536            "base dir named: {msg}"
537        );
538        assert!(
539            msg.contains(cli_a.path().to_str().unwrap()),
540            "cli_a dir named: {msg}"
541        );
542        assert!(
543            msg.contains(cli_b.path().to_str().unwrap()),
544            "cli_b dir named: {msg}"
545        );
546        assert!(msg.contains("cascade"), "message flags cascade: {msg}");
547    }
548
549    #[test]
550    fn env_includes_split_multi_paths() {
551        // `env_blueprint_includes` splits `MSE_BLUEPRINT_INCLUDES` on the
552        // platform separator. Set → read → unset to avoid poisoning
553        // sibling tests.
554        let old = std::env::var_os("MSE_BLUEPRINT_INCLUDES");
555        let sep = if cfg!(windows) { ';' } else { ':' };
556        std::env::set_var(
557            "MSE_BLUEPRINT_INCLUDES",
558            format!("/tmp/aaa{sep}/tmp/bbb{sep}/tmp/ccc"),
559        );
560        let got = env_blueprint_includes();
561        // Restore before asserting so a failed assert still leaves the
562        // env clean.
563        match old {
564            Some(v) => std::env::set_var("MSE_BLUEPRINT_INCLUDES", v),
565            None => std::env::remove_var("MSE_BLUEPRINT_INCLUDES"),
566        }
567        assert_eq!(
568            got,
569            vec![
570                PathBuf::from("/tmp/aaa"),
571                PathBuf::from("/tmp/bbb"),
572                PathBuf::from("/tmp/ccc"),
573            ]
574        );
575    }
576
577    #[test]
578    fn in_bp_includes_reader_returns_empty_when_absent() {
579        let bp = json!({ "id": "no-includes" });
580        assert!(pre_read_in_bp_includes(&bp).is_empty());
581
582        let bp2 = json!({
583            "id": "with-includes",
584            "blueprint_ref_includes": ["ext/agents", "vendor/samples"],
585        });
586        assert_eq!(
587            pre_read_in_bp_includes(&bp2),
588            vec![PathBuf::from("ext/agents"), PathBuf::from("vendor/samples")]
589        );
590    }
591
592    #[test]
593    fn absolute_and_parent_escape_still_rejected_across_cascade() {
594        // Hygiene stays regardless of which cascade tiers are configured
595        // — absolute paths and `..` are rejected before any tier is
596        // walked.
597        let base = TempDir::new().unwrap();
598        let extra = TempDir::new().unwrap();
599        let cfg = ResolveConfig::new(base.path().to_path_buf())
600            .with_cli_includes(vec![extra.path().to_path_buf()]);
601
602        let err_abs = expand_file_refs_with_config(
603            json!({ "$file": "/etc/passwd" }),
604            &cfg,
605            AgentKind::Operator,
606        )
607        .expect_err("absolute rejected");
608        assert!(
609            format!("{err_abs}").contains("absolute path"),
610            "got: {err_abs}"
611        );
612
613        let err_parent = expand_file_refs_with_config(
614            json!({ "$file": "../escape.md" }),
615            &cfg,
616            AgentKind::Operator,
617        )
618        .expect_err(".. rejected");
619        assert!(
620            format!("{err_parent}").contains("parent-dir escape"),
621            "got: {err_parent}"
622        );
623    }
624
625    #[test]
626    fn bundled_default_used_only_when_no_other_match() {
627        // Bundled default (tier 6) is the last resort — used only when
628        // none of tiers 1-5 match.
629        let base_dir = TempDir::new().unwrap();
630        let bundled_dir = TempDir::new().unwrap();
631        write_md(bundled_dir.path(), "prompts/y.md", "from-bundled");
632
633        let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
634            .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
635        let bp = json!({ "$file": "prompts/y.md" });
636        let resolved =
637            expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
638        assert_eq!(resolved, json!("from-bundled"));
639    }
640}