Skip to main content

agent_runtime/render/
writer.rs

1//! Per-product render writer. Walks the skills declared for the active
2//! product, renders each via Tera + helpers when its input hash differs
3//! from the recorded cache entry, and writes the output under
4//! `<source-root>/build/<product>/`. Skills with a cache hit are left
5//! on disk verbatim — the determinism contract guarantees the cache-hit
6//! path is byte-identical to a fresh render of the same source.
7//!
8//! Render only opens paths under `<source-root>/`. Every read is rooted
9//! through [`source_path`](Self::source_path) and joined to the
10//! canonical source root, so a malicious `skill.source` like
11//! `../../etc` lands outside the source root and is rejected before any
12//! I/O happens.
13
14use crate::render::cache::{AGENTS_CACHE_FILE, CACHE_FILE, CacheEntry, RenderCache};
15use crate::render::helpers::{HelperContext, register_all};
16use crate::render::manifest::{Agent, ManifestSet, Skill, SourceRoot};
17use anyhow::{Context, Result, anyhow};
18use nils_markdown::Engine;
19use sha2::{Digest, Sha256};
20use std::fs;
21use std::io::ErrorKind;
22use std::path::{Component, Path, PathBuf};
23use std::sync::Arc;
24
25pub const SKILL_TEMPLATE_FILE: &str = "SKILL.md.tera";
26/// Required canonical template under each `core/agents/<name>/` source
27/// dir. Mirrors [`SKILL_TEMPLATE_FILE`]; the rendered output lands at the
28/// product's `render_to` (e.g. `agents/<name>.toml`).
29pub const AGENT_TEMPLATE_FILE: &str = "AGENT.md.tera";
30pub const HOME_PROMPT_FILE: &str = "AGENT_HOME.md";
31pub const NEUTRAL_HOME_PRODUCT: &str = "neutral";
32const TERA_EXT: &str = "tera";
33
34/// One file under a skill source directory. The path is relative to the
35/// skill source root (e.g. `SKILL.md.tera`, `bin/topic_radar.py`); the
36/// caller joins it against the canonical source dir before opening.
37#[derive(Debug)]
38struct SourceFile {
39    rel: PathBuf,
40    abs: PathBuf,
41    /// Unix permission bits (mode & 0o777), used to preserve the
42    /// executable bit on shell scripts when copying. Absent under
43    /// `#[cfg(not(unix))]` builds; the hash and copy paths fall back
44    /// to a constant on those platforms.
45    #[cfg(unix)]
46    mode: u32,
47}
48
49#[derive(Debug, Default, PartialEq, Eq)]
50pub struct RenderReport {
51    pub product: String,
52    pub output_root: PathBuf,
53    pub rendered: Vec<String>,
54    pub cached: Vec<String>,
55    pub skipped: Vec<String>,
56}
57
58#[derive(Debug, PartialEq, Eq)]
59pub struct HomePromptReport {
60    pub product: String,
61    pub output_path: PathBuf,
62    pub rendered: bool,
63}
64
65/// Render every skill declared for `product` from manifests rooted at
66/// `root` into the default `<source-root>/build/<product>/` tree.
67///
68/// For renders that need a custom output destination (the
69/// audit-drift rendered-target diff class renders into a scratch
70/// dir to diff against the live build), use [`write_product_to`].
71pub fn write_product(
72    root: &SourceRoot,
73    manifests: Arc<ManifestSet>,
74    product: &str,
75) -> Result<RenderReport> {
76    let output_root = default_product_output_root(root, product);
77    reject_unsafe_default_output_root(root, &output_root)?;
78    write_product_to(root, manifests, product, &output_root)
79}
80
81/// Render variant that writes into `output_root` rather than the
82/// default `<source-root>/build/<product>/`. The output root must
83/// exist or be creatable; the symlink-escape and `..`-traversal
84/// guards apply *relative to* the caller-provided `output_root`, so
85/// the caller is responsible for choosing a safe root (audit-drift
86/// uses a fresh `TempDir`).
87///
88/// Renders the skills surface and the optional agents surface into the
89/// same `output_root`. The two surfaces keep independent cache files
90/// ([`CACHE_FILE`] / [`AGENTS_CACHE_FILE`]) so neither reconciles away the
91/// other's outputs on save. The returned report merges the rendered,
92/// cached, and skipped ids from both surfaces.
93pub(crate) fn write_product_to(
94    root: &SourceRoot,
95    manifests: Arc<ManifestSet>,
96    product: &str,
97    output_root: &Path,
98) -> Result<RenderReport> {
99    let mut report = write_skills_to(root, manifests.clone(), product, output_root)?;
100    let agents = write_agents_to(root, manifests, product, output_root)?;
101    report.rendered.extend(agents.rendered);
102    report.cached.extend(agents.cached);
103    report.skipped.extend(agents.skipped);
104    write_home_prompt_to(root, product, output_root, false)?;
105    Ok(report)
106}
107
108pub fn write_home_prompt(
109    root: &SourceRoot,
110    product: &str,
111    require_source: bool,
112) -> Result<HomePromptReport> {
113    let output_root = default_product_output_root(root, product);
114    reject_unsafe_default_output_root(root, &output_root)?;
115    write_home_prompt_to(root, product, &output_root, require_source)
116}
117
118fn default_product_output_root(root: &SourceRoot, product: &str) -> PathBuf {
119    root.path().join("build").join(product)
120}
121
122fn reject_unsafe_default_output_root(root: &SourceRoot, output_root: &Path) -> Result<()> {
123    let source_root = root.path();
124    let build_root = source_root.join("build");
125    reject_existing_symlink(&build_root, "default render build directory")?;
126    reject_existing_symlink(output_root, "default render output root")?;
127
128    if let Some(canonical_build_root) = canonicalize_if_exists(&build_root)? {
129        if !canonical_build_root.starts_with(source_root) {
130            return Err(anyhow!(
131                "default render build directory {} resolves outside the source root \
132                 ({} not under {}) — refusing to write",
133                build_root.display(),
134                canonical_build_root.display(),
135                source_root.display(),
136            ));
137        }
138
139        if let Some(canonical_output_root) = canonicalize_if_exists(output_root)?
140            && !canonical_output_root.starts_with(&canonical_build_root)
141        {
142            return Err(anyhow!(
143                "default render output root {} resolves outside the build directory \
144                 ({} not under {}) — refusing to write",
145                output_root.display(),
146                canonical_output_root.display(),
147                canonical_build_root.display(),
148            ));
149        }
150    }
151
152    Ok(())
153}
154
155fn reject_existing_symlink(path: &Path, label: &str) -> Result<()> {
156    match fs::symlink_metadata(path) {
157        Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!(
158            "{label} {} is a symlink; refusing to use it as a render root",
159            path.display()
160        )),
161        Ok(_) => Ok(()),
162        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
163        Err(err) => Err(err).with_context(|| format!("stat {label} {}", path.display())),
164    }
165}
166
167fn canonicalize_if_exists(path: &Path) -> Result<Option<PathBuf>> {
168    match path.canonicalize() {
169        Ok(path) => Ok(Some(path)),
170        Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
171        Err(err) => Err(err).with_context(|| format!("canonicalize {}", path.display())),
172    }
173}
174
175fn write_home_prompt_to(
176    root: &SourceRoot,
177    product: &str,
178    output_root: &Path,
179    require_source: bool,
180) -> Result<HomePromptReport> {
181    let source = root.path().join(HOME_PROMPT_FILE);
182    let output_root = output_root.to_path_buf();
183    let output_path = output_root.join(HOME_PROMPT_FILE);
184    if !source.exists() {
185        if require_source {
186            return Err(anyhow!(
187                "home prompt source {} is missing",
188                source.display()
189            ));
190        }
191        remove_stale_home_prompt(&output_root, &output_path)?;
192        return Ok(HomePromptReport {
193            product: product.to_string(),
194            output_path,
195            rendered: false,
196        });
197    }
198
199    fs::create_dir_all(&output_root)
200        .with_context(|| format!("create_dir_all {}", output_root.display()))?;
201    let canonical_source_root = root.path().to_path_buf();
202    let canonical_output_root = output_root
203        .canonicalize()
204        .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
205    let source = canonicalize_under(&canonical_source_root, &source)?;
206    let body = fs::read_to_string(&source)
207        .with_context(|| format!("read home prompt {}", source.display()))?;
208    let rendered = render_home_prompt_template(product, &body)?;
209    let output_path = guard_write_under(&canonical_output_root, &output_path)?;
210    reject_leaf_symlink(&output_path)?;
211    fs::write(&output_path, rendered.as_bytes())
212        .with_context(|| format!("write {}", output_path.display()))?;
213
214    Ok(HomePromptReport {
215        product: product.to_string(),
216        output_path,
217        rendered: true,
218    })
219}
220
221fn remove_stale_home_prompt(output_root: &Path, output_path: &Path) -> Result<()> {
222    let metadata = match fs::symlink_metadata(output_path) {
223        Ok(metadata) => metadata,
224        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
225        Err(err) => {
226            return Err(err)
227                .with_context(|| format!("stat stale home prompt {}", output_path.display()));
228        }
229    };
230    let canonical_output_root = output_root
231        .canonicalize()
232        .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
233    let guarded_output = guard_write_under(&canonical_output_root, output_path)?;
234    if !metadata.file_type().is_symlink() {
235        let canonical_output = guarded_output
236            .canonicalize()
237            .with_context(|| format!("canonicalize stale home prompt {}", output_path.display()))?;
238        if !canonical_output.starts_with(&canonical_output_root) {
239            return Err(anyhow!(
240                "stale home prompt output {} resolves outside the build root \
241                 ({} not under {}) — refusing to remove",
242                output_path.display(),
243                canonical_output.display(),
244                canonical_output_root.display(),
245            ));
246        }
247    }
248    fs::remove_file(&guarded_output)
249        .with_context(|| format!("remove stale home prompt {}", guarded_output.display()))?;
250    prune_empty_dirs_upward(output_root, &canonical_output_root, HOME_PROMPT_FILE);
251    Ok(())
252}
253
254/// Render every skill declared for `product` into `output_root`. This is
255/// the original per-product writer; the optional agents surface renders
256/// separately through [`write_agents_to`] against its own cache file.
257fn write_skills_to(
258    root: &SourceRoot,
259    manifests: Arc<ManifestSet>,
260    product: &str,
261    output_root: &Path,
262) -> Result<RenderReport> {
263    require_known_product(&manifests, product)?;
264    let output_root = output_root.to_path_buf();
265    fs::create_dir_all(&output_root)
266        .with_context(|| format!("create_dir_all {}", output_root.display()))?;
267
268    let cache_path = output_root.join(CACHE_FILE);
269    let prior_cache = RenderCache::load_or_empty(&cache_path);
270    let manifest_bytes = read_manifest_bundle(root)?;
271    let mut next_cache = RenderCache::empty();
272    let mut report = RenderReport {
273        product: product.to_string(),
274        output_root: output_root.clone(),
275        ..RenderReport::default()
276    };
277
278    let canonical_source_root = root.path().to_path_buf();
279    let canonical_output_root = output_root
280        .canonicalize()
281        .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
282
283    for skill in &manifests.skills.skills {
284        let Some(render) = skill.products.get(product) else {
285            report.skipped.push(skill.id.clone());
286            continue;
287        };
288        // `render_to` is the output path RELATIVE to `<source-root>/build/<product>/`,
289        // including the filename for the rendered SKILL.md (e.g.
290        // `plugins/reporting/skills/daily-brief/SKILL.md`). Manifests that include
291        // a leading `build/<product>/` segment produce doubled output paths
292        // (`build/<product>/build/<product>/...`) because the binary already
293        // prepends `build/<product>/`. Reject that shape with a helpful error
294        // so the source manifest can be fixed once, not silently broken.
295        validate_render_to(&skill.id, product, &render.render_to)?;
296
297        let source_dir = sandboxed_join(root.path(), &skill.source)?;
298        let canonical_source_dir = canonicalize_under(&canonical_source_root, &source_dir)?;
299        let source_files = walk_skill_source(&canonical_source_dir, &canonical_source_root)
300            .with_context(|| {
301                format!(
302                    "walk source for skill {} at {}",
303                    skill.id,
304                    canonical_source_dir.display()
305                )
306            })?;
307        let template_file = source_files
308            .iter()
309            .find(|f| f.rel == Path::new(SKILL_TEMPLATE_FILE))
310            .ok_or_else(|| {
311                anyhow!(
312                    "skill {} source {} is missing required {SKILL_TEMPLATE_FILE}",
313                    skill.id,
314                    skill.source
315                )
316            })?;
317        let template_body = fs::read_to_string(&template_file.abs).with_context(|| {
318            format!(
319                "read template {} for skill {}",
320                template_file.abs.display(),
321                skill.id
322            )
323        })?;
324
325        // Pre-compute the set of paths this skill will write (relative
326        // to `build/<product>/`). Used for the cache entry and for
327        // surgical removal of stale prior outputs that two-skills-share-
328        // a-dir layouts make impossible to handle with `remove_dir_all`.
329        let render_to_rel = PathBuf::from(&render.render_to);
330        let output_dir_rel = render_to_rel.parent().ok_or_else(|| {
331            anyhow!(
332                "render_to {:?} for skill {} has no parent dir",
333                render.render_to,
334                skill.id,
335            )
336        })?;
337        let mut planned_outputs: Vec<String> = Vec::with_capacity(source_files.len());
338        for file in &source_files {
339            let rel = if file.rel == Path::new(SKILL_TEMPLATE_FILE) {
340                render_to_rel.clone()
341            } else {
342                output_dir_rel.join(strip_tera_suffix(&file.rel))
343            };
344            planned_outputs.push(rel.to_string_lossy().into_owned());
345        }
346        planned_outputs.sort();
347        planned_outputs.dedup();
348
349        let input_hash = input_hash(
350            product,
351            &skill.id,
352            &render.render_to,
353            &source_files,
354            &canonical_source_dir,
355            &manifest_bytes,
356        )
357        .with_context(|| format!("hash source tree for skill {}", skill.id))?;
358        let entry = CacheEntry {
359            hash: input_hash.clone(),
360            outputs: planned_outputs.clone(),
361        };
362        let output_path = sandboxed_join(&output_root, &render.render_to)?;
363        let cache_hit = prior_cache
364            .skills
365            .get(&skill.id)
366            .is_some_and(|prior| prior == &entry)
367            && output_path.exists();
368
369        if cache_hit {
370            report.cached.push(skill.id.clone());
371        } else {
372            // Cache miss: remove files this skill wrote on the prior
373            // run that are NOT in `planned_outputs` (renamed or deleted
374            // siblings). Crucially, we only touch paths recorded in
375            // *this* skill's prior cache entry — files owned by sibling
376            // skills that happen to share a parent dir (e.g.
377            // `sample.determinism` writes `skills/sample/SKILL.md` and
378            // `sample.codex-only` writes `skills/sample/CODEX_ONLY.md`)
379            // are never disturbed.
380            if let Some(prior) = prior_cache.skills.get(&skill.id) {
381                let planned: std::collections::BTreeSet<&String> = planned_outputs.iter().collect();
382                for stale in &prior.outputs {
383                    if planned.contains(stale) {
384                        continue;
385                    }
386                    let stale_path = sandboxed_join(&output_root, stale)?;
387                    if !stale_path.exists() {
388                        continue;
389                    }
390                    // Symlink-escape guard: a hostile cache entry could
391                    // record a path that — combined with a symlink
392                    // pre-staged at that location — points outside the
393                    // build root. Canonicalize and re-verify before any
394                    // unlink.
395                    let canonical_stale = stale_path.canonicalize().with_context(|| {
396                        format!("canonicalize stale output {}", stale_path.display())
397                    })?;
398                    if !canonical_stale.starts_with(&canonical_output_root) {
399                        return Err(anyhow!(
400                            "stale rendered output {} resolves outside the build root \
401                             ({} not under {}) — refusing to remove",
402                            stale_path.display(),
403                            canonical_stale.display(),
404                            canonical_output_root.display(),
405                        ));
406                    }
407                    fs::remove_file(&canonical_stale).with_context(|| {
408                        format!("remove stale rendered file {}", canonical_stale.display())
409                    })?;
410                }
411            }
412
413            // Render the SKILL template and write it at `render_to`.
414            if let Some(parent) = output_path.parent() {
415                fs::create_dir_all(parent)
416                    .with_context(|| format!("create_dir_all {}", parent.display()))?;
417            }
418            let rendered =
419                render_template(root.path(), &manifests, product, skill, &template_body)?;
420            // Same symlink-escape guard for writes: canonicalize the
421            // parent (which we just ensured exists) and verify it stays
422            // beneath `<source-root>/build/<product>/`. A hostile
423            // `render_to` of `../../etc/passwd` is already rejected by
424            // sandboxed_join; this also catches `dir-that-is-a-symlink/foo`.
425            let output_path_guarded = guard_write_under(&canonical_output_root, &output_path)?;
426            fs::write(&output_path_guarded, rendered.as_bytes())
427                .with_context(|| format!("write {}", output_path_guarded.display()))?;
428
429            // Walk every other source file. Sibling .tera files are
430            // rendered through the same helper context (the suffix is
431            // stripped from the destination filename); non-.tera files
432            // are byte-copied verbatim with the original mode preserved
433            // so executables stay executable on disk.
434            for file in &source_files {
435                if file.rel == Path::new(SKILL_TEMPLATE_FILE) {
436                    continue;
437                }
438                let dest_rel = strip_tera_suffix(&file.rel);
439                let dest = sandboxed_join(
440                    &output_root,
441                    &output_dir_rel.join(&dest_rel).to_string_lossy(),
442                )?;
443                if let Some(parent) = dest.parent() {
444                    fs::create_dir_all(parent)
445                        .with_context(|| format!("create_dir_all {}", parent.display()))?;
446                }
447                let dest = guard_write_under(&canonical_output_root, &dest)?;
448                if file.rel.extension().and_then(|e| e.to_str()) == Some(TERA_EXT) {
449                    let body = fs::read_to_string(&file.abs).with_context(|| {
450                        format!(
451                            "read sibling tera template {} for skill {}",
452                            file.abs.display(),
453                            skill.id
454                        )
455                    })?;
456                    let rendered = render_template(root.path(), &manifests, product, skill, &body)?;
457                    fs::write(&dest, rendered.as_bytes())
458                        .with_context(|| format!("write {}", dest.display()))?;
459                } else {
460                    // `fs::copy` follows symlinks at the source. If the
461                    // source is itself a hostile symlink that points
462                    // outside the source root, we've already rejected
463                    // it during the `walk_skill_source` canonicalize-
464                    // under check, so this is safe.
465                    fs::copy(&file.abs, &dest).with_context(|| {
466                        format!("copy {} -> {}", file.abs.display(), dest.display())
467                    })?;
468                    #[cfg(unix)]
469                    {
470                        use std::os::unix::fs::PermissionsExt;
471                        let perms = fs::Permissions::from_mode(file.mode);
472                        fs::set_permissions(&dest, perms).with_context(|| {
473                            format!("set mode {:#o} on {}", file.mode, dest.display())
474                        })?;
475                    }
476                }
477            }
478            report.rendered.push(skill.id.clone());
479        }
480        next_cache.skills.insert(skill.id.clone(), entry);
481    }
482
483    // Reconcile retired skills. The per-skill cleanup above only fires for
484    // skills still being rendered, so render is otherwise additive: a skill
485    // removed from the manifest (or moved off this product) leaves its
486    // outputs behind in `build/<product>/`. That stale tree is not just
487    // untidy — `prune-stale` rebuilds its "expected" set by expanding the
488    // recursive link-map entry over the current `build/` tree, so a retired
489    // skill that lingers in build/ is treated as still-expected and silently
490    // kept in the live home (`candidates=0`). Removing the retired outputs
491    // here closes both gaps from one place.
492    reconcile_retired_skills(
493        &prior_cache,
494        &next_cache,
495        &output_root,
496        &canonical_output_root,
497    )?;
498
499    next_cache
500        .save(&cache_path)
501        .with_context(|| format!("write {}", cache_path.display()))?;
502    Ok(report)
503}
504
505/// Render every agent declared in the optional `agents.yaml` for
506/// `product` into `output_root`. Structurally mirrors [`write_skills_to`]
507/// — same sandboxing, stale-output removal, sibling rendering, and retired
508/// reconcile — but keys its cache on [`AGENTS_CACHE_FILE`], requires the
509/// [`AGENT_TEMPLATE_FILE`] canonical template, and renders through
510/// [`render_agent_template`]. A tree with no agents (the common case)
511/// returns an empty report and only writes the agents cache file.
512fn write_agents_to(
513    root: &SourceRoot,
514    manifests: Arc<ManifestSet>,
515    product: &str,
516    output_root: &Path,
517) -> Result<RenderReport> {
518    require_known_product(&manifests, product)?;
519    let output_root = output_root.to_path_buf();
520    fs::create_dir_all(&output_root)
521        .with_context(|| format!("create_dir_all {}", output_root.display()))?;
522
523    let cache_path = output_root.join(AGENTS_CACHE_FILE);
524    let prior_cache = RenderCache::load_or_empty(&cache_path);
525    let manifest_bytes = read_manifest_bundle(root)?;
526    let mut next_cache = RenderCache::empty();
527    let mut report = RenderReport {
528        product: product.to_string(),
529        output_root: output_root.clone(),
530        ..RenderReport::default()
531    };
532
533    let canonical_source_root = root.path().to_path_buf();
534    let canonical_output_root = output_root
535        .canonicalize()
536        .with_context(|| format!("canonicalize output root {}", output_root.display()))?;
537
538    for agent in &manifests.agents.agents {
539        let Some(render) = agent.products.get(product) else {
540            report.skipped.push(agent.id.clone());
541            continue;
542        };
543        validate_render_to(&agent.id, product, &render.render_to)?;
544
545        let source_dir = sandboxed_join(root.path(), &agent.source)?;
546        let canonical_source_dir = canonicalize_under(&canonical_source_root, &source_dir)?;
547        let source_files = walk_skill_source(&canonical_source_dir, &canonical_source_root)
548            .with_context(|| {
549                format!(
550                    "walk source for agent {} at {}",
551                    agent.id,
552                    canonical_source_dir.display()
553                )
554            })?;
555        let template_file = source_files
556            .iter()
557            .find(|f| f.rel == Path::new(AGENT_TEMPLATE_FILE))
558            .ok_or_else(|| {
559                anyhow!(
560                    "agent {} source {} is missing required {AGENT_TEMPLATE_FILE}",
561                    agent.id,
562                    agent.source
563                )
564            })?;
565        let template_body = fs::read_to_string(&template_file.abs).with_context(|| {
566            format!(
567                "read template {} for agent {}",
568                template_file.abs.display(),
569                agent.id
570            )
571        })?;
572
573        let render_to_rel = PathBuf::from(&render.render_to);
574        let output_dir_rel = render_to_rel.parent().ok_or_else(|| {
575            anyhow!(
576                "render_to {:?} for agent {} has no parent dir",
577                render.render_to,
578                agent.id,
579            )
580        })?;
581        let mut planned_outputs: Vec<String> = Vec::with_capacity(source_files.len());
582        for file in &source_files {
583            let rel = if file.rel == Path::new(AGENT_TEMPLATE_FILE) {
584                render_to_rel.clone()
585            } else {
586                output_dir_rel.join(strip_tera_suffix(&file.rel))
587            };
588            planned_outputs.push(rel.to_string_lossy().into_owned());
589        }
590        planned_outputs.sort();
591        planned_outputs.dedup();
592
593        let input_hash = input_hash(
594            product,
595            &agent.id,
596            &render.render_to,
597            &source_files,
598            &canonical_source_dir,
599            &manifest_bytes,
600        )
601        .with_context(|| format!("hash source tree for agent {}", agent.id))?;
602        let entry = CacheEntry {
603            hash: input_hash.clone(),
604            outputs: planned_outputs.clone(),
605        };
606        let output_path = sandboxed_join(&output_root, &render.render_to)?;
607        let cache_hit = prior_cache
608            .skills
609            .get(&agent.id)
610            .is_some_and(|prior| prior == &entry)
611            && output_path.exists();
612
613        if cache_hit {
614            report.cached.push(agent.id.clone());
615        } else {
616            // Same surgical stale-file removal as the skills path: only
617            // touch paths recorded in this agent's prior cache entry.
618            if let Some(prior) = prior_cache.skills.get(&agent.id) {
619                let planned: std::collections::BTreeSet<&String> = planned_outputs.iter().collect();
620                for stale in &prior.outputs {
621                    if planned.contains(stale) {
622                        continue;
623                    }
624                    let stale_path = sandboxed_join(&output_root, stale)?;
625                    if !stale_path.exists() {
626                        continue;
627                    }
628                    let canonical_stale = stale_path.canonicalize().with_context(|| {
629                        format!("canonicalize stale output {}", stale_path.display())
630                    })?;
631                    if !canonical_stale.starts_with(&canonical_output_root) {
632                        return Err(anyhow!(
633                            "stale rendered output {} resolves outside the build root \
634                             ({} not under {}) — refusing to remove",
635                            stale_path.display(),
636                            canonical_stale.display(),
637                            canonical_output_root.display(),
638                        ));
639                    }
640                    fs::remove_file(&canonical_stale).with_context(|| {
641                        format!("remove stale rendered file {}", canonical_stale.display())
642                    })?;
643                }
644            }
645
646            if let Some(parent) = output_path.parent() {
647                fs::create_dir_all(parent)
648                    .with_context(|| format!("create_dir_all {}", parent.display()))?;
649            }
650            let rendered =
651                render_agent_template(root.path(), &manifests, product, agent, &template_body)?;
652            let output_path_guarded = guard_write_under(&canonical_output_root, &output_path)?;
653            fs::write(&output_path_guarded, rendered.as_bytes())
654                .with_context(|| format!("write {}", output_path_guarded.display()))?;
655
656            for file in &source_files {
657                if file.rel == Path::new(AGENT_TEMPLATE_FILE) {
658                    continue;
659                }
660                let dest_rel = strip_tera_suffix(&file.rel);
661                let dest = sandboxed_join(
662                    &output_root,
663                    &output_dir_rel.join(&dest_rel).to_string_lossy(),
664                )?;
665                if let Some(parent) = dest.parent() {
666                    fs::create_dir_all(parent)
667                        .with_context(|| format!("create_dir_all {}", parent.display()))?;
668                }
669                let dest = guard_write_under(&canonical_output_root, &dest)?;
670                if file.rel.extension().and_then(|e| e.to_str()) == Some(TERA_EXT) {
671                    let body = fs::read_to_string(&file.abs).with_context(|| {
672                        format!(
673                            "read sibling tera template {} for agent {}",
674                            file.abs.display(),
675                            agent.id
676                        )
677                    })?;
678                    let rendered =
679                        render_agent_template(root.path(), &manifests, product, agent, &body)?;
680                    fs::write(&dest, rendered.as_bytes())
681                        .with_context(|| format!("write {}", dest.display()))?;
682                } else {
683                    fs::copy(&file.abs, &dest).with_context(|| {
684                        format!("copy {} -> {}", file.abs.display(), dest.display())
685                    })?;
686                    #[cfg(unix)]
687                    {
688                        use std::os::unix::fs::PermissionsExt;
689                        let perms = fs::Permissions::from_mode(file.mode);
690                        fs::set_permissions(&dest, perms).with_context(|| {
691                            format!("set mode {:#o} on {}", file.mode, dest.display())
692                        })?;
693                    }
694                }
695            }
696            report.rendered.push(agent.id.clone());
697        }
698        next_cache.skills.insert(agent.id.clone(), entry);
699    }
700
701    // `reconcile_retired_skills` is generic over the `RenderCache.skills`
702    // map; here it reconciles retired *agents* against the agents cache.
703    reconcile_retired_skills(
704        &prior_cache,
705        &next_cache,
706        &output_root,
707        &canonical_output_root,
708    )?;
709
710    next_cache
711        .save(&cache_path)
712        .with_context(|| format!("write {}", cache_path.display()))?;
713    Ok(report)
714}
715
716/// Remove `build/<product>/` outputs for skills recorded in `prior_cache`
717/// that are absent from this run's `next_cache` (retired from the manifest
718/// or moved off this product). Each file is removed with the same
719/// canonicalize-under-output-root guard the cache-miss path uses; the
720/// directories the removals empty are pruned upward, stopping at
721/// `output_root`. Paths still owned by a present skill (shared output) are
722/// never removed, and a shared parent dir that stays non-empty is left in
723/// place, so sibling skills sharing a directory are unaffected.
724fn reconcile_retired_skills(
725    prior_cache: &RenderCache,
726    next_cache: &RenderCache,
727    output_root: &Path,
728    canonical_output_root: &Path,
729) -> Result<()> {
730    // Paths a still-present skill writes this run must never be removed,
731    // even if a retired skill also recorded them.
732    let live_outputs: std::collections::BTreeSet<&String> = next_cache
733        .skills
734        .values()
735        .flat_map(|entry| entry.outputs.iter())
736        .collect();
737
738    for (skill_id, prior) in &prior_cache.skills {
739        if next_cache.skills.contains_key(skill_id) {
740            continue;
741        }
742        for rel in &prior.outputs {
743            if live_outputs.contains(rel) {
744                continue;
745            }
746            let path = sandboxed_join(output_root, rel)?;
747            // May already be gone (manual cleanup, or a path another retired
748            // skill removed first). `symlink_metadata` avoids following a
749            // dangling symlink.
750            if fs::symlink_metadata(&path).is_err() {
751                continue;
752            }
753            // Symlink-escape guard, mirroring the cache-miss removal path: a
754            // hostile cache entry combined with a pre-staged symlink could
755            // resolve outside the build root. Canonicalize and re-verify
756            // before any unlink.
757            let canonical = path
758                .canonicalize()
759                .with_context(|| format!("canonicalize retired output {}", path.display()))?;
760            if !canonical.starts_with(canonical_output_root) {
761                return Err(anyhow!(
762                    "retired rendered output {} resolves outside the build root \
763                     ({} not under {}) — refusing to remove",
764                    path.display(),
765                    canonical.display(),
766                    canonical_output_root.display(),
767                ));
768            }
769            fs::remove_file(&canonical)
770                .with_context(|| format!("remove retired rendered file {}", canonical.display()))?;
771        }
772        // Prune the directories the removals emptied. Done after all files
773        // for this skill are gone so a leaf dir whose siblings were also
774        // retire-owned collapses fully.
775        for rel in &prior.outputs {
776            prune_empty_dirs_upward(output_root, canonical_output_root, rel);
777        }
778    }
779    Ok(())
780}
781
782/// Remove now-empty ancestor directories of a removed output file, walking
783/// from the file's parent up toward `output_root`. Stops at the first
784/// directory that is non-empty (a sibling still owns content), missing, or
785/// resolves to / above `output_root`. Best-effort: a failed `remove_dir`
786/// (e.g. a race) simply ends the climb without erroring the render.
787fn prune_empty_dirs_upward(output_root: &Path, canonical_output_root: &Path, rel: &str) {
788    let mut dir = match PathBuf::from(rel).parent() {
789        Some(parent) if !parent.as_os_str().is_empty() => output_root.join(parent),
790        _ => return,
791    };
792    // Climb ends as soon as a directory no longer canonicalizes (already
793    // removed or never existed).
794    while let Ok(canonical) = dir.canonicalize() {
795        if canonical == *canonical_output_root || !canonical.starts_with(canonical_output_root) {
796            break;
797        }
798        let is_empty = match fs::read_dir(&canonical) {
799            Ok(mut entries) => entries.next().is_none(),
800            Err(_) => break,
801        };
802        if !is_empty {
803            break; // a sibling still owns content here
804        }
805        if fs::remove_dir(&canonical).is_err() {
806            break;
807        }
808        match dir.parent() {
809            Some(parent) => dir = parent.to_path_buf(),
810            None => break,
811        }
812    }
813}
814
815/// Reject `render_to` values that start with `build/<product>/` (or the
816/// generic `build/` prefix). The output root is already
817/// `<source-root>/build/<product>/`; a `render_to` like
818/// `build/codex/plugins/...` doubles the prefix to
819/// `build/codex/build/codex/plugins/...`. The render cache happily records
820/// the (undoubled) intended path but the on-disk file lives at the doubled
821/// path, which produces audit-drift confusion and silently broken installs.
822///
823/// The canonical form is the path **relative to** `build/<product>/`,
824/// **including** the rendered filename. Per the source-doc canonical
825/// example for the reporting POC:
826///
827/// ```yaml
828/// products:
829///   codex:
830///     render_to: plugins/reporting/skills/daily-brief/SKILL.md
831///   claude:
832///     render_to: plugins/reporting/skills/daily-brief/SKILL.md
833/// ```
834fn validate_render_to(skill_id: &str, product: &str, render_to: &str) -> Result<()> {
835    let leading = render_to.split('/').next().unwrap_or(render_to);
836    if leading == "build" {
837        return Err(anyhow!(
838            "render_to {render_to:?} for skill {skill_id} (product {product}) starts with \
839             `build/`; the binary already prepends `build/{product}/` to the value, so this \
840             shape would double the prefix. Use a path relative to `build/{product}/` \
841             (including the rendered filename), e.g. `plugins/<plugin>/skills/<skill>/SKILL.md`.",
842        ));
843    }
844    Ok(())
845}
846
847fn require_known_product(manifests: &ManifestSet, product: &str) -> Result<()> {
848    match product {
849        "codex" | "claude" | "hermes" => Ok(()),
850        other => Err(anyhow!(
851            "unknown --product {other:?}; supported: codex, claude, hermes. \
852             schema_version={}",
853            manifests.product_capabilities.schema_version
854        )),
855    }
856}
857
858fn render_template(
859    source_root: &Path,
860    manifests: &Arc<ManifestSet>,
861    product: &str,
862    skill: &Skill,
863    template_body: &str,
864) -> Result<String> {
865    let ctx = HelperContext {
866        source_root: source_root.to_path_buf(),
867        manifests: manifests.clone(),
868        current_product: product.to_string(),
869        current_skill_id: skill.id.clone(),
870        current_skill_required_clis: skill.required_clis.clone(),
871        current_skill_state_out_mode: skill.state_out_mode,
872    };
873    let mut engine = Engine::builder().build();
874    register_all(&mut engine, Arc::new(ctx));
875    let vars = serde_json::json!({ "product": product });
876    engine
877        .render_str(template_body, &vars)
878        .with_context(|| format!("render skill {}", skill.id))
879}
880
881/// Render an agent template. Mirrors [`render_template`] but builds the
882/// helper context from an [`Agent`] (which carries no `required_clis` or
883/// `state_out_mode` of its own) and exposes the active `product` and agent
884/// `id` as Tera variables, so one canonical `AGENT.md.tera` can branch to
885/// Codex TOML vs Claude Markdown. The skill-bound helpers stay registered
886/// for `cli_ref` reuse; agent templates are not expected to call
887/// `skill_ref` / `state_out`.
888fn render_agent_template(
889    source_root: &Path,
890    manifests: &Arc<ManifestSet>,
891    product: &str,
892    agent: &Agent,
893    template_body: &str,
894) -> Result<String> {
895    let ctx = HelperContext {
896        source_root: source_root.to_path_buf(),
897        manifests: manifests.clone(),
898        current_product: product.to_string(),
899        current_skill_id: agent.id.clone(),
900        current_skill_required_clis: Default::default(),
901        current_skill_state_out_mode: Default::default(),
902    };
903    let mut engine = Engine::builder().build();
904    register_all(&mut engine, Arc::new(ctx));
905    let vars = serde_json::json!({ "product": product, "id": agent.id });
906    engine
907        .render_str(template_body, &vars)
908        .with_context(|| format!("render agent {}", agent.id))
909}
910
911fn render_home_prompt_template(product: &str, template_body: &str) -> Result<String> {
912    let mut engine = Engine::builder().build();
913    let vars = serde_json::json!({ "product": product });
914    engine
915        .render_str(template_body, &vars)
916        .context("render home prompt")
917}
918
919struct ManifestBytes {
920    skills: Vec<u8>,
921    plugins: Vec<u8>,
922    product_capabilities: Vec<u8>,
923    runtime_roots: Vec<u8>,
924    cli_tools: Vec<u8>,
925    agents: Vec<u8>,
926}
927
928fn read_manifest_bundle(root: &SourceRoot) -> Result<ManifestBytes> {
929    let dir = root.manifests_dir();
930    let read = |name: &str| -> Result<Vec<u8>> {
931        let path = dir.join(name);
932        fs::read(&path).with_context(|| format!("hash-read {}", path.display()))
933    };
934    // `agents.yaml` is optional; absence hashes as empty bytes so a tree
935    // without the file keeps a stable digest.
936    let read_optional = |name: &str| -> Result<Vec<u8>> {
937        let path = dir.join(name);
938        if !path.exists() {
939            return Ok(Vec::new());
940        }
941        fs::read(&path).with_context(|| format!("hash-read {}", path.display()))
942    };
943    Ok(ManifestBytes {
944        skills: read("skills.yaml")?,
945        plugins: read("plugins.yaml")?,
946        product_capabilities: read("product-capabilities.yaml")?,
947        runtime_roots: read("runtime-roots.yaml")?,
948        cli_tools: read("cli-tools.yaml")?,
949        agents: read_optional("agents.yaml")?,
950    })
951}
952
953fn input_hash(
954    product: &str,
955    id: &str,
956    render_to: &str,
957    source_files: &[SourceFile],
958    canonical_source_dir: &Path,
959    manifests: &ManifestBytes,
960) -> Result<String> {
961    // Hash version bumped to v3 when the agents render surface landed:
962    // the manifest bundle now folds in `agents.yaml`, so every prior
963    // cache entry is auto-invalidated by the version tag (v2 entries no
964    // longer match) and re-rendered byte-identically. v2 itself landed
965    // with multi-file render; v0.13 entries were invalidated then.
966    let mut hasher = Sha256::new();
967    hasher.update(b"agent-runtime-cli render v3\0");
968    hasher.update(product.as_bytes());
969    hasher.update(b"\0");
970    hasher.update(id.as_bytes());
971    hasher.update(b"\0");
972    hasher.update(render_to.as_bytes());
973    hasher.update(b"\0");
974    // Hash every file under the skill source dir. The walk returns the
975    // entries sorted by relative path so the digest is reproducible
976    // across processes and filesystems with non-deterministic readdir
977    // ordering.
978    for file in source_files {
979        let rel = file.rel.to_string_lossy();
980        hasher.update(rel.as_bytes());
981        hasher.update(b"\0");
982        // Include the mode so a chmod-only change still invalidates the
983        // cache. On non-unix platforms the field is absent; fold a
984        // constant in so the hash space stays identical across builds
985        // of the same platform.
986        #[cfg(unix)]
987        {
988            hasher.update(file.mode.to_le_bytes());
989        }
990        #[cfg(not(unix))]
991        {
992            hasher.update([0u8; 4]);
993        }
994        hasher.update(b"\0");
995        let bytes = fs::read(&file.abs).with_context(|| {
996            format!(
997                "hash-read {} (skill source dir {})",
998                file.abs.display(),
999                canonical_source_dir.display()
1000            )
1001        })?;
1002        hasher.update(&bytes);
1003        hasher.update(b"\0");
1004    }
1005    // Whole-file manifest bytes — keeps the hash sensitive to any change
1006    // in a file the helpers might consume, at the cost of a coarse cache
1007    // invalidation when an unrelated manifest line shifts.
1008    hasher.update(&manifests.skills);
1009    hasher.update(b"\0");
1010    hasher.update(&manifests.plugins);
1011    hasher.update(b"\0");
1012    hasher.update(&manifests.product_capabilities);
1013    hasher.update(b"\0");
1014    hasher.update(&manifests.runtime_roots);
1015    hasher.update(b"\0");
1016    hasher.update(&manifests.cli_tools);
1017    hasher.update(b"\0");
1018    hasher.update(&manifests.agents);
1019    let digest = hasher.finalize();
1020    let mut out = String::with_capacity(7 + digest.len() * 2);
1021    out.push_str("sha256:");
1022    for byte in digest.iter() {
1023        use std::fmt::Write;
1024        let _ = write!(&mut out, "{byte:02x}");
1025    }
1026    Ok(out)
1027}
1028
1029/// Walk a skill source directory recursively and return every file with
1030/// its path relative to the skill root. Directories are descended in
1031/// sorted order so the resulting entry list (and the hash derived from
1032/// it) is deterministic across filesystems with arbitrary readdir order.
1033///
1034/// Symlinks are followed via the same `canonicalize_under` guard used
1035/// for the SKILL template read: a hostile sibling symlink that points
1036/// outside the canonical source root is rejected before any I/O.
1037fn walk_skill_source(skill_dir: &Path, canonical_source_root: &Path) -> Result<Vec<SourceFile>> {
1038    let mut out = Vec::new();
1039    walk_dir(skill_dir, skill_dir, canonical_source_root, &mut out)?;
1040    out.sort_by(|a, b| a.rel.cmp(&b.rel));
1041    Ok(out)
1042}
1043
1044fn walk_dir(
1045    skill_root: &Path,
1046    dir: &Path,
1047    canonical_source_root: &Path,
1048    out: &mut Vec<SourceFile>,
1049) -> Result<()> {
1050    let entries = fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))?;
1051    let mut paths: Vec<PathBuf> = entries
1052        .map(|e| e.map(|entry| entry.path()))
1053        .collect::<std::io::Result<_>>()
1054        .with_context(|| format!("read_dir entries under {}", dir.display()))?;
1055    paths.sort();
1056    for path in paths {
1057        // Each entry — file or directory — gets the canonical-under
1058        // guard so a hostile symlink under, say, `bin/` can't escape
1059        // the source root.
1060        let canonical = canonicalize_under(canonical_source_root, &path)?;
1061        let meta = fs::metadata(&canonical)
1062            .with_context(|| format!("metadata {}", canonical.display()))?;
1063        if meta.is_dir() {
1064            walk_dir(skill_root, &canonical, canonical_source_root, out)?;
1065            continue;
1066        }
1067        if !meta.is_file() {
1068            continue;
1069        }
1070        let rel = canonical.strip_prefix(skill_root).map_err(|err| {
1071            anyhow!(
1072                "source file {} is not under skill root {}: {err}",
1073                canonical.display(),
1074                skill_root.display(),
1075            )
1076        })?;
1077        #[cfg(unix)]
1078        let mode = {
1079            use std::os::unix::fs::PermissionsExt;
1080            meta.permissions().mode() & 0o777
1081        };
1082        let source = SourceFile {
1083            rel: rel.to_path_buf(),
1084            abs: canonical.clone(),
1085            #[cfg(unix)]
1086            mode,
1087        };
1088        out.push(source);
1089    }
1090    Ok(())
1091}
1092
1093/// Strip a trailing `.tera` extension from `rel` so a sibling like
1094/// `prompts/intro.md.tera` lands as `prompts/intro.md` in the rendered
1095/// tree. Files without a `.tera` extension pass through unchanged.
1096fn strip_tera_suffix(rel: &Path) -> PathBuf {
1097    if rel.extension().and_then(|e| e.to_str()) == Some(TERA_EXT) {
1098        rel.with_extension("")
1099    } else {
1100        rel.to_path_buf()
1101    }
1102}
1103
1104/// Resolve a candidate read path and assert the canonical result is
1105/// still under `canonical_base`. Defeats symlink-based sandbox escape
1106/// where a hostile `core/skills/<x>/SKILL.md.tera` symlinks to a file
1107/// outside the source root (e.g. `/etc/passwd`). The path must exist;
1108/// the caller is reading it.
1109pub(crate) fn canonicalize_under(canonical_base: &Path, candidate: &Path) -> Result<PathBuf> {
1110    let resolved = candidate
1111        .canonicalize()
1112        .with_context(|| format!("canonicalize {}", candidate.display()))?;
1113    if !resolved.starts_with(canonical_base) {
1114        return Err(anyhow!(
1115            "path {candidate} resolves outside the source root \
1116             ({resolved} not under {canonical_base}) — likely a symlink escape",
1117            candidate = candidate.display(),
1118            resolved = resolved.display(),
1119            canonical_base = canonical_base.display(),
1120        ));
1121    }
1122    Ok(resolved)
1123}
1124
1125/// Resolve a candidate write path. The file itself may not exist yet,
1126/// so we canonicalize the parent (which the caller created via
1127/// `create_dir_all`) and assert it sits under `canonical_base`. A
1128/// hostile parent symlink — e.g. `build/<product>/foo` is a symlink to
1129/// `/etc/` — gets rejected before we open the file for write.
1130pub(crate) fn guard_write_under(canonical_base: &Path, candidate: &Path) -> Result<PathBuf> {
1131    let parent = candidate
1132        .parent()
1133        .ok_or_else(|| anyhow!("render output path {} has no parent", candidate.display()))?;
1134    let canonical_parent = parent
1135        .canonicalize()
1136        .with_context(|| format!("canonicalize parent of {}", candidate.display()))?;
1137    if !canonical_parent.starts_with(canonical_base) {
1138        return Err(anyhow!(
1139            "render output {} resolves outside the build root \
1140             ({} not under {}) — likely a symlink escape",
1141            candidate.display(),
1142            canonical_parent.display(),
1143            canonical_base.display(),
1144        ));
1145    }
1146    let file_name = candidate.file_name().ok_or_else(|| {
1147        anyhow!(
1148            "render output path {} has no file name",
1149            candidate.display()
1150        )
1151    })?;
1152    Ok(canonical_parent.join(file_name))
1153}
1154
1155fn reject_leaf_symlink(path: &Path) -> Result<()> {
1156    match fs::symlink_metadata(path) {
1157        Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!(
1158            "render output {} is a symlink; refusing to follow a leaf symlink",
1159            path.display()
1160        )),
1161        Ok(_) => Ok(()),
1162        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
1163        Err(err) => Err(err).with_context(|| format!("stat render output {}", path.display())),
1164    }
1165}
1166
1167/// Join `relative` onto `base` after rejecting any `..` segments. Used
1168/// for every render-time path so we cannot escape `<source-root>/` via
1169/// a hostile `skill.source`, `render_to`, or `--source-root` value.
1170pub(crate) fn sandboxed_join(base: &Path, relative: &str) -> Result<PathBuf> {
1171    let rel = PathBuf::from(relative);
1172    for component in rel.components() {
1173        match component {
1174            Component::Normal(_) | Component::CurDir => {}
1175            Component::ParentDir => {
1176                return Err(anyhow!(
1177                    "path {relative:?} contains a `..` segment; render must stay under {base}",
1178                    base = base.display(),
1179                ));
1180            }
1181            Component::RootDir | Component::Prefix(_) => {
1182                return Err(anyhow!(
1183                    "path {relative:?} is absolute; render must stay under {base}",
1184                    base = base.display(),
1185                ));
1186            }
1187        }
1188    }
1189    Ok(base.join(rel))
1190}
1191
1192/// Map of the rendered output bytes keyed by path-under-output-root.
1193/// Test helper for cache-hit-vs-cache-miss byte equality assertions.
1194#[cfg(test)]
1195pub(crate) fn snapshot_outputs(output_root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
1196    let mut out = std::collections::BTreeMap::new();
1197    walk(output_root, output_root, &mut out);
1198    out
1199}
1200
1201#[cfg(test)]
1202fn walk(base: &Path, dir: &Path, out: &mut std::collections::BTreeMap<String, Vec<u8>>) {
1203    let Ok(entries) = fs::read_dir(dir) else {
1204        return;
1205    };
1206    let mut entries: Vec<_> = entries.flatten().collect();
1207    entries.sort_by_key(|e| e.path());
1208    for entry in entries {
1209        let path = entry.path();
1210        if path.is_dir() {
1211            walk(base, &path, out);
1212            continue;
1213        }
1214        if path.file_name().and_then(|n| n.to_str()) == Some(CACHE_FILE) {
1215            continue;
1216        }
1217        let bytes = fs::read(&path).unwrap();
1218        let rel = path
1219            .strip_prefix(base)
1220            .unwrap()
1221            .to_string_lossy()
1222            .into_owned();
1223        out.insert(rel, bytes);
1224    }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230    use crate::render::cache::RenderCache;
1231    use tempfile::TempDir;
1232
1233    fn write(path: &Path, body: &str) {
1234        if let Some(parent) = path.parent() {
1235            fs::create_dir_all(parent).unwrap();
1236        }
1237        fs::write(path, body).unwrap();
1238    }
1239
1240    const SKILLS_FIXTURE: &str = r#"
1241schema_version: 1
1242skills:
1243  - id: market.favorites
1244    domain: market
1245    source: core/skills/market/favorites
1246    products:
1247      codex:
1248        name: /market-favorites
1249        render_to: skills/market/favorites/SKILL.md
1250      claude:
1251        name: market:favorites
1252        render_to: plugins/market/skills/favorites/SKILL.md
1253    required_clis:
1254      agent-out: ">=0.5.0"
1255      market-cli: ">=0.4.0"
1256"#;
1257
1258    /// Build a working source root with one skill that exercises every
1259    /// helper (script / skill_ref / state_out / cli_ref).
1260    fn fixture_source_root(tmp: &TempDir) -> SourceRoot {
1261        let root = tmp.path();
1262        write(&root.join("manifests/skills.yaml"), SKILLS_FIXTURE);
1263        write(
1264            &root.join("manifests/plugins.yaml"),
1265            "schema_version: 1\nplugins: []\n",
1266        );
1267        write(
1268            &root.join("manifests/product-capabilities.yaml"),
1269            PRODUCT_CAPS,
1270        );
1271        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1272        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1273
1274        // Skill template exercises every helper. Trailing newline is
1275        // intentional so the rendered file ends with one.
1276        write(
1277            &root.join("core/skills/market/favorites/SKILL.md.tera"),
1278            r#"# {{ skill_ref(id="market.favorites") }}
1279
1280state: {{ state_out(domain="market", topic="favorites") }}
1281script: {{ script(path="core/scripts/market.sh") }}
1282required: {{ cli_ref(name="agent-out") }} via {{ cli_ref(name="market-cli") }}
1283"#,
1284        );
1285
1286        SourceRoot::from_arg_or_cwd(Some(root)).unwrap()
1287    }
1288
1289    const PRODUCT_CAPS: &str = r#"
1290schema_version: 1
1291products:
1292  codex:
1293    nested_skill_support: true
1294    plugin_manifest:
1295      path_pattern: "ignored"
1296      loaded_at_runtime: false
1297      schema_ref: "ignored"
1298    hooks_model:
1299      config_surface: "ignored"
1300      payload_shape: "ignored"
1301      supports_inline_python: false
1302    config_activation:
1303      - "$CODEX_HOME/AGENTS.md"
1304    runtime_state:
1305      state_home_env: "STATE"
1306      default_path: "/tmp/state"
1307  claude:
1308    nested_skill_support: true
1309    plugin_manifest:
1310      path_pattern: "ignored"
1311      loaded_at_runtime: true
1312      schema_ref: "ignored"
1313    hooks_model:
1314      config_surface: "ignored"
1315      payload_shape: "ignored"
1316      supports_inline_python: true
1317    config_activation:
1318      - "$HOME/.claude/settings.json"
1319    runtime_state:
1320      state_home_env: "STATE"
1321      default_path: "/tmp/state"
1322  hermes:
1323    nested_skill_support: true
1324    plugin_manifest:
1325      path_pattern: "ignored"
1326      loaded_at_runtime: false
1327      schema_ref: "ignored"
1328    hooks_model:
1329      config_surface: "n/a"
1330      payload_shape: "n/a"
1331      supports_inline_python: false
1332    config_activation:
1333      - "$HOME/.hermes/skills"
1334    runtime_state:
1335      state_home_env: "STATE"
1336      default_path: "/tmp/state"
1337"#;
1338
1339    const RUNTIME_ROOTS: &str = r#"
1340schema_version: 1
1341products:
1342  codex:
1343    live_home: "$CODEX_HOME"
1344    docs_home: "$CODEX_HOME"
1345    state_home: "/tmp/state"
1346    plugin_root: "$CODEX_HOME/plugins"
1347    hook_config_strategy: managed-block
1348    min_version: "<TBD: pin during Phase 1>"
1349    recommended_version: "<TBD: pin during Phase 1>"
1350    min_version_effective_from: "<TBD: pin during Phase 1>"
1351    version_probe: "codex --version"
1352  claude:
1353    live_home: "$HOME/.claude"
1354    docs_home: "$HOME/.claude"
1355    state_home: "/tmp/state"
1356    plugin_root_env: "CLAUDE_PLUGIN_ROOT"
1357    hook_config_strategy: settings-json
1358    min_version: "<TBD: pin during Phase 1>"
1359    recommended_version: "<TBD: pin during Phase 1>"
1360    min_version_effective_from: "<TBD: pin during Phase 1>"
1361    version_probe: "claude --version"
1362  hermes:
1363    live_home: "$HOME/.hermes"
1364    docs_home: "$HOME/.hermes"
1365    state_home: "/tmp/state"
1366    min_version: "1.0.0"
1367    recommended_version: "1.0.0"
1368    min_version_effective_from: "<TBD>"
1369    version_probe: "hermes --version"
1370"#;
1371
1372    const CLI_TOOLS: &str = r#"
1373schema_version: 1
1374profiles:
1375  core: [ripgrep]
1376  recommended: [ripgrep]
1377  full: [ripgrep]
1378formulas:
1379  ripgrep:
1380    brew: ripgrep
1381    command: rg
1382    linux_only_alternative: null
1383    categories: [search]
1384"#;
1385
1386    fn load_set(root: &SourceRoot) -> Arc<ManifestSet> {
1387        Arc::new(crate::render::manifest::load_all(root).unwrap())
1388    }
1389
1390    /// Drop an optional `manifests/agents.yaml` and one canonical agent
1391    /// source onto an existing fixture root. The single `AGENT.md.tera`
1392    /// branches on the `product` template variable so it can emit Codex
1393    /// TOML or Claude Markdown from one source.
1394    fn add_agent_fixture(root: &SourceRoot) {
1395        write(
1396            &root.path().join("manifests/agents.yaml"),
1397            r#"
1398schema_version: 1
1399agents:
1400  - id: reviewer-quick
1401    source: core/agents/reviewer-quick
1402    products:
1403      codex:
1404        render_to: agents/reviewer-quick.toml
1405      claude:
1406        render_to: agents/reviewer-quick.md
1407"#,
1408        );
1409        write(
1410            &root.path().join("core/agents/reviewer-quick/AGENT.md.tera"),
1411            "{% if product == \"codex\" %}name = \"reviewer-quick\"\n\
1412             {% else %}---\nname: reviewer-quick\n---\n{% endif %}",
1413        );
1414    }
1415
1416    #[test]
1417    fn write_product_renders_codex_agent_into_build_tree() {
1418        let tmp = TempDir::new().unwrap();
1419        let root = fixture_source_root(&tmp);
1420        add_agent_fixture(&root);
1421        let set = load_set(&root);
1422
1423        let report = write_product(&root, set, "codex").unwrap();
1424
1425        let out = report.output_root.join("agents/reviewer-quick.toml");
1426        assert!(out.exists(), "expected agent render at {}", out.display());
1427        let body = fs::read_to_string(&out).unwrap();
1428        assert!(body.contains("name = \"reviewer-quick\""), "{body}");
1429        assert!(
1430            report.rendered.iter().any(|id| id == "reviewer-quick"),
1431            "agent id absent from rendered report: {:?}",
1432            report.rendered
1433        );
1434    }
1435
1436    #[test]
1437    fn write_product_renders_claude_agent_with_product_branch() {
1438        let tmp = TempDir::new().unwrap();
1439        let root = fixture_source_root(&tmp);
1440        add_agent_fixture(&root);
1441        let set = load_set(&root);
1442
1443        let report = write_product(&root, set, "claude").unwrap();
1444
1445        // The one canonical AGENT.md.tera branched on `product` to the
1446        // Claude Markdown arm and landed at the claude `render_to`.
1447        let out = report.output_root.join("agents/reviewer-quick.md");
1448        let body = fs::read_to_string(&out).unwrap();
1449        assert!(body.contains("---\nname: reviewer-quick"), "{body}");
1450        assert!(!body.contains("name = \"reviewer-quick\""), "{body}");
1451        assert!(report.rendered.iter().any(|id| id == "reviewer-quick"));
1452    }
1453
1454    #[test]
1455    fn agent_render_is_cached_on_second_run() {
1456        let tmp = TempDir::new().unwrap();
1457        let root = fixture_source_root(&tmp);
1458        add_agent_fixture(&root);
1459        let set = load_set(&root);
1460
1461        let first = write_product(&root, set.clone(), "codex").unwrap();
1462        assert!(first.rendered.iter().any(|id| id == "reviewer-quick"));
1463
1464        // Second run with unchanged source: the agents cache (its own
1465        // `.render-cache-agents.json`) reports a hit, not a re-render.
1466        let second = write_product(&root, set, "codex").unwrap();
1467        assert!(
1468            second.cached.iter().any(|id| id == "reviewer-quick"),
1469            "expected agent cache hit, got rendered={:?} cached={:?}",
1470            second.rendered,
1471            second.cached
1472        );
1473    }
1474
1475    #[test]
1476    fn write_product_renders_codex_skill_into_build_tree() {
1477        let tmp = TempDir::new().unwrap();
1478        let root = fixture_source_root(&tmp);
1479        let set = load_set(&root);
1480
1481        let report = write_product(&root, set, "codex").unwrap();
1482
1483        assert_eq!(report.rendered, vec!["market.favorites".to_string()]);
1484        assert!(report.cached.is_empty());
1485        assert!(report.skipped.is_empty());
1486        let out = report.output_root.join("skills/market/favorites/SKILL.md");
1487        let body = fs::read_to_string(&out).unwrap();
1488        assert!(body.contains("# /market-favorites"), "{body}");
1489        assert!(
1490            body.contains("state: agent-out path-for --domain market --topic favorites"),
1491            "{body}",
1492        );
1493        assert!(
1494            body.contains("script: ") && body.contains("/core/scripts/market.sh"),
1495            "{body}",
1496        );
1497        assert!(
1498            body.contains("required: agent-out (>=0.5.0) via market-cli (>=0.4.0)"),
1499            "{body}",
1500        );
1501
1502        // Cache file exists after the run.
1503        let cache = RenderCache::load_or_empty(&report.output_root.join(CACHE_FILE));
1504        assert!(cache.skills.contains_key("market.favorites"));
1505    }
1506
1507    #[test]
1508    fn cache_hit_skips_render_and_keeps_existing_output_bytes() {
1509        let tmp = TempDir::new().unwrap();
1510        let root = fixture_source_root(&tmp);
1511        let set = load_set(&root);
1512
1513        // First run populates output + cache.
1514        let first = write_product(&root, set.clone(), "codex").unwrap();
1515        let snapshot_first = snapshot_outputs(&first.output_root);
1516        assert_eq!(first.rendered, vec!["market.favorites".to_string()]);
1517
1518        // Second run with no input change must hit the cache and leave
1519        // the output bytes identical.
1520        let second = write_product(&root, set.clone(), "codex").unwrap();
1521        assert!(second.rendered.is_empty(), "{:?}", second.rendered);
1522        assert_eq!(second.cached, vec!["market.favorites".to_string()]);
1523        let snapshot_second = snapshot_outputs(&second.output_root);
1524        assert_eq!(snapshot_first, snapshot_second);
1525    }
1526
1527    #[test]
1528    fn cache_miss_after_cache_file_deletion_reproduces_identical_bytes() {
1529        let tmp = TempDir::new().unwrap();
1530        let root = fixture_source_root(&tmp);
1531        let set = load_set(&root);
1532
1533        let first = write_product(&root, set.clone(), "codex").unwrap();
1534        let snapshot_first = snapshot_outputs(&first.output_root);
1535
1536        // Delete the cache file → forces a re-render. The rendered
1537        // bytes must match the first run byte-for-byte.
1538        fs::remove_file(first.output_root.join(CACHE_FILE)).unwrap();
1539        let second = write_product(&root, set, "codex").unwrap();
1540        assert_eq!(second.rendered, vec!["market.favorites".to_string()]);
1541        let snapshot_second = snapshot_outputs(&second.output_root);
1542        assert_eq!(snapshot_first, snapshot_second);
1543    }
1544
1545    #[test]
1546    fn template_change_invalidates_cache_and_re_renders() {
1547        let tmp = TempDir::new().unwrap();
1548        let root = fixture_source_root(&tmp);
1549        let set = load_set(&root);
1550        write_product(&root, set.clone(), "codex").unwrap();
1551
1552        // Touch the template body.
1553        let tpl_path = root
1554            .path()
1555            .join("core/skills/market/favorites/SKILL.md.tera");
1556        let mut body = fs::read_to_string(&tpl_path).unwrap();
1557        body.push_str("\nextra line\n");
1558        fs::write(&tpl_path, body).unwrap();
1559
1560        // Reload manifests (skill source bytes changed; manifest bytes
1561        // unchanged → cache key still differs because template body is
1562        // part of the hash input).
1563        let set = load_set(&root);
1564        let second = write_product(&root, set, "codex").unwrap();
1565        assert_eq!(second.rendered, vec!["market.favorites".to_string()]);
1566        let rendered =
1567            fs::read_to_string(second.output_root.join("skills/market/favorites/SKILL.md"))
1568                .unwrap();
1569        assert!(rendered.ends_with("extra line\n"), "{rendered}");
1570    }
1571
1572    #[test]
1573    fn skill_without_product_entry_is_skipped() {
1574        let tmp = TempDir::new().unwrap();
1575        let root = tmp.path();
1576        write(
1577            &root.join("manifests/skills.yaml"),
1578            r#"
1579schema_version: 1
1580skills:
1581  - id: codex.only
1582    domain: codex
1583    source: core/skills/codex/only
1584    products:
1585      codex:
1586        render_to: skills/codex-only/SKILL.md
1587    required_clis: {}
1588"#,
1589        );
1590        write(
1591            &root.join("manifests/plugins.yaml"),
1592            "schema_version: 1\nplugins: []\n",
1593        );
1594        write(
1595            &root.join("manifests/product-capabilities.yaml"),
1596            PRODUCT_CAPS,
1597        );
1598        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1599        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1600        write(
1601            &root.join("core/skills/codex/only/SKILL.md.tera"),
1602            "# codex-only\n",
1603        );
1604        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1605        let set = load_set(&source_root);
1606        let report = write_product(&source_root, set, "claude").unwrap();
1607        assert!(report.rendered.is_empty());
1608        assert_eq!(report.skipped, vec!["codex.only".to_string()]);
1609    }
1610
1611    #[test]
1612    fn render_rejects_unknown_product() {
1613        let tmp = TempDir::new().unwrap();
1614        let root = fixture_source_root(&tmp);
1615        let set = load_set(&root);
1616        let err = write_product(&root, set, "unknown").unwrap_err();
1617        assert!(format!("{err:#}").contains("unknown --product"));
1618    }
1619
1620    #[test]
1621    fn sandboxed_join_rejects_parent_segments_and_absolute_paths() {
1622        let base = Path::new("/tmp/source-root");
1623        sandboxed_join(base, "core/scripts/foo.sh").unwrap();
1624        sandboxed_join(base, "./core/scripts/foo.sh").unwrap();
1625        let err = sandboxed_join(base, "../etc/passwd").unwrap_err();
1626        assert!(format!("{err}").contains(".."));
1627        let err = sandboxed_join(base, "/etc/passwd").unwrap_err();
1628        assert!(format!("{err}").contains("absolute"));
1629    }
1630
1631    /// A hostile `skill.source` directory could contain a symlinked
1632    /// SKILL.md.tera that points outside the source root. The lexical
1633    /// `sandboxed_join` accepts the path (no `..`, not absolute), so
1634    /// `canonicalize_under` catches the escape just before the read.
1635    /// Without that guard the renderer would expose `/etc/passwd` (or
1636    /// any readable file) into rendered output.
1637    #[cfg(unix)]
1638    #[test]
1639    fn symlinked_skill_template_outside_source_root_is_rejected() {
1640        let tmp = TempDir::new().unwrap();
1641        let root = fixture_source_root(&tmp);
1642        let set = load_set(&root);
1643        // Replace the legitimate template with a symlink pointing at
1644        // a file outside the source root.
1645        let outside = TempDir::new().unwrap();
1646        let target = outside.path().join("hostile.tera");
1647        fs::write(&target, "# captured from outside\n").unwrap();
1648        let template_path = root
1649            .path()
1650            .join("core/skills/market/favorites/SKILL.md.tera");
1651        fs::remove_file(&template_path).unwrap();
1652        std::os::unix::fs::symlink(&target, &template_path).unwrap();
1653
1654        let err = write_product(&root, set, "codex").unwrap_err();
1655        let msg = format!("{err:#}");
1656        assert!(
1657            msg.contains("symlink") || msg.contains("outside the source root"),
1658            "{msg}",
1659        );
1660    }
1661
1662    /// Same threat surface as the read-path test, but for the write
1663    /// path: a hostile `build/<product>/` symlink could redirect render
1664    /// output into the user's home directory. `guard_write_under`
1665    /// canonicalizes the parent before fs::write opens it.
1666    #[cfg(unix)]
1667    #[test]
1668    fn symlinked_build_dir_outside_root_is_rejected_for_writes() {
1669        let tmp = TempDir::new().unwrap();
1670        let root = fixture_source_root(&tmp);
1671        let set = load_set(&root);
1672        // Pre-create build/<product>/, then swap one nested dir for a
1673        // symlink pointing outside the canonical build root.
1674        let build = root.path().join("build/codex");
1675        fs::create_dir_all(&build).unwrap();
1676        let dest = build.join("skills/market/favorites");
1677        fs::create_dir_all(dest.parent().unwrap()).unwrap();
1678        let outside = TempDir::new().unwrap();
1679        let exfil = outside.path().join("favorites");
1680        fs::create_dir(&exfil).unwrap();
1681        std::os::unix::fs::symlink(&exfil, &dest).unwrap();
1682
1683        let err = write_product(&root, set, "codex").unwrap_err();
1684        let msg = format!("{err:#}");
1685        assert!(
1686            msg.contains("outside the build root") || msg.contains("symlink"),
1687            "{msg}",
1688        );
1689    }
1690
1691    #[test]
1692    fn render_rejects_render_to_with_build_prefix() {
1693        // A `render_to` value that starts with `build/<product>/` would
1694        // double the prefix because the binary already prepends
1695        // `build/<product>/` to the output root. The validator should
1696        // reject this shape with a clear pointer at the canonical form.
1697        let tmp = TempDir::new().unwrap();
1698        let root = tmp.path();
1699        write(
1700            &root.join("manifests/skills.yaml"),
1701            r#"
1702schema_version: 1
1703skills:
1704  - id: market.favorites
1705    domain: market
1706    source: core/skills/market/favorites
1707    products:
1708      codex:
1709        name: /market-favorites
1710        render_to: build/codex/plugins/market/skills/favorites/SKILL.md
1711    required_clis: {}
1712"#,
1713        );
1714        write(
1715            &root.join("manifests/plugins.yaml"),
1716            "schema_version: 1\nplugins: []\n",
1717        );
1718        write(
1719            &root.join("manifests/product-capabilities.yaml"),
1720            PRODUCT_CAPS,
1721        );
1722        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1723        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1724        write(
1725            &root.join("core/skills/market/favorites/SKILL.md.tera"),
1726            "# market\n",
1727        );
1728        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1729        let set = load_set(&source_root);
1730        let err = write_product(&source_root, set, "codex").unwrap_err();
1731        let msg = format!("{err:#}");
1732        assert!(msg.contains("starts with `build/`"), "{msg}");
1733        assert!(msg.contains("market.favorites"), "{msg}");
1734        assert!(
1735            msg.contains("plugins/<plugin>/skills/<skill>/SKILL.md"),
1736            "{msg}"
1737        );
1738    }
1739
1740    #[test]
1741    fn write_product_copies_sibling_files_with_executable_bit() {
1742        // A skill that ships `bin/`, `scripts/`, `references/` siblings
1743        // (the topic-radar shape) should land all of them under the
1744        // rendered output directory, with shell scripts keeping their
1745        // executable bit. Without this, the rendered SKILL points at a
1746        // script path that doesn't exist in the build tree.
1747        let tmp = TempDir::new().unwrap();
1748        let root = tmp.path();
1749        write(
1750            &root.join("manifests/skills.yaml"),
1751            r#"
1752schema_version: 1
1753skills:
1754  - id: tools.topic-radar
1755    domain: tools
1756    source: core/skills/tools/topic-radar
1757    products:
1758      codex:
1759        name: topic-radar
1760        render_to: plugins/tools/skills/topic-radar/SKILL.md
1761    required_clis: {}
1762"#,
1763        );
1764        write(
1765            &root.join("manifests/plugins.yaml"),
1766            "schema_version: 1\nplugins: []\n",
1767        );
1768        write(
1769            &root.join("manifests/product-capabilities.yaml"),
1770            PRODUCT_CAPS,
1771        );
1772        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1773        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1774
1775        let skill_src = root.join("core/skills/tools/topic-radar");
1776        write(&skill_src.join("SKILL.md.tera"), "# topic-radar\n");
1777        write(&skill_src.join("bin/topic_radar.py"), "print('hello')\n");
1778        write(
1779            &skill_src.join("scripts/topic-radar.sh"),
1780            "#!/bin/sh\necho hi\n",
1781        );
1782        write(
1783            &skill_src.join("references/source-strategy.md"),
1784            "# strategy\n",
1785        );
1786        #[cfg(unix)]
1787        {
1788            use std::os::unix::fs::PermissionsExt;
1789            fs::set_permissions(
1790                skill_src.join("scripts/topic-radar.sh"),
1791                fs::Permissions::from_mode(0o755),
1792            )
1793            .unwrap();
1794        }
1795
1796        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1797        let set = load_set(&source_root);
1798        let report = write_product(&source_root, set, "codex").unwrap();
1799        assert_eq!(report.rendered, vec!["tools.topic-radar".to_string()]);
1800
1801        let out_dir = report.output_root.join("plugins/tools/skills/topic-radar");
1802        assert!(
1803            out_dir.join("SKILL.md").exists(),
1804            "rendered SKILL.md missing"
1805        );
1806        assert!(
1807            out_dir.join("bin/topic_radar.py").exists(),
1808            "bin/topic_radar.py not copied"
1809        );
1810        assert_eq!(
1811            fs::read_to_string(out_dir.join("bin/topic_radar.py")).unwrap(),
1812            "print('hello')\n",
1813        );
1814        assert_eq!(
1815            fs::read_to_string(out_dir.join("scripts/topic-radar.sh")).unwrap(),
1816            "#!/bin/sh\necho hi\n",
1817        );
1818        assert_eq!(
1819            fs::read_to_string(out_dir.join("references/source-strategy.md")).unwrap(),
1820            "# strategy\n",
1821        );
1822        #[cfg(unix)]
1823        {
1824            use std::os::unix::fs::PermissionsExt;
1825            let copied_mode = fs::metadata(out_dir.join("scripts/topic-radar.sh"))
1826                .unwrap()
1827                .permissions()
1828                .mode()
1829                & 0o777;
1830            assert_eq!(
1831                copied_mode, 0o755,
1832                "executable bit not preserved on rendered shell script",
1833            );
1834        }
1835    }
1836
1837    #[test]
1838    fn sibling_tera_file_is_rendered_through_helpers_and_drops_suffix() {
1839        // A `.tera` sibling (e.g. `prompts/intro.md.tera`) should be
1840        // rendered through the same helper context as the SKILL body
1841        // and land in the output as `prompts/intro.md` (no `.tera`
1842        // suffix) so downstream tooling consumes a plain file.
1843        let tmp = TempDir::new().unwrap();
1844        let root = tmp.path();
1845        write(
1846            &root.join("manifests/skills.yaml"),
1847            r#"
1848schema_version: 1
1849skills:
1850  - id: market.favorites
1851    domain: market
1852    source: core/skills/market/favorites
1853    products:
1854      codex:
1855        name: favorites
1856        render_to: skills/market/favorites/SKILL.md
1857    required_clis:
1858      agent-out: ">=0.5.0"
1859"#,
1860        );
1861        write(
1862            &root.join("manifests/plugins.yaml"),
1863            "schema_version: 1\nplugins: []\n",
1864        );
1865        write(
1866            &root.join("manifests/product-capabilities.yaml"),
1867            PRODUCT_CAPS,
1868        );
1869        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1870        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1871
1872        let skill_src = root.join("core/skills/market/favorites");
1873        write(&skill_src.join("SKILL.md.tera"), "# favorites\n");
1874        write(
1875            &skill_src.join("prompts/intro.md.tera"),
1876            r#"intro for {{ skill_ref(id="market.favorites") }}"#,
1877        );
1878
1879        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1880        let set = load_set(&source_root);
1881        write_product(&source_root, set, "codex").unwrap();
1882
1883        let out = root.join("build/codex/skills/market/favorites/prompts/intro.md");
1884        assert!(
1885            out.exists(),
1886            "rendered sibling tera should land without .tera suffix"
1887        );
1888        let body = fs::read_to_string(&out).unwrap();
1889        assert_eq!(body, "intro for favorites");
1890    }
1891
1892    #[test]
1893    fn stale_sibling_files_are_removed_on_re_render() {
1894        // If a sibling file is removed from source between two renders,
1895        // the prior rendered copy must not survive in the output. The
1896        // cache-miss path clears the output dir, so deleting a source
1897        // file is enough to make it disappear from `build/`.
1898        let tmp = TempDir::new().unwrap();
1899        let root = tmp.path();
1900        write(
1901            &root.join("manifests/skills.yaml"),
1902            r#"
1903schema_version: 1
1904skills:
1905  - id: tools.foo
1906    domain: tools
1907    source: core/skills/tools/foo
1908    products:
1909      codex:
1910        name: foo
1911        render_to: plugins/tools/skills/foo/SKILL.md
1912    required_clis: {}
1913"#,
1914        );
1915        write(
1916            &root.join("manifests/plugins.yaml"),
1917            "schema_version: 1\nplugins: []\n",
1918        );
1919        write(
1920            &root.join("manifests/product-capabilities.yaml"),
1921            PRODUCT_CAPS,
1922        );
1923        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1924        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1925
1926        let skill_src = root.join("core/skills/tools/foo");
1927        write(&skill_src.join("SKILL.md.tera"), "# foo\n");
1928        write(&skill_src.join("old-helper.sh"), "echo old\n");
1929
1930        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1931        let set = load_set(&source_root);
1932        write_product(&source_root, set.clone(), "codex").unwrap();
1933        let out_dir = root.join("build/codex/plugins/tools/skills/foo");
1934        assert!(out_dir.join("old-helper.sh").exists());
1935
1936        // Delete the sibling from source; re-load + re-render.
1937        fs::remove_file(skill_src.join("old-helper.sh")).unwrap();
1938        let set = load_set(&source_root);
1939        write_product(&source_root, set, "codex").unwrap();
1940        assert!(
1941            !out_dir.join("old-helper.sh").exists(),
1942            "stale rendered sibling must be cleaned on re-render",
1943        );
1944        assert!(
1945            out_dir.join("SKILL.md").exists(),
1946            "SKILL.md should still render after sibling removal",
1947        );
1948    }
1949
1950    #[test]
1951    fn sibling_byte_change_invalidates_cache() {
1952        // A pure sibling-file edit (no SKILL.md.tera change) must still
1953        // invalidate the cache so the rendered output picks up the new
1954        // bytes — otherwise users editing a helper script see stale
1955        // output on the next render.
1956        let tmp = TempDir::new().unwrap();
1957        let root = tmp.path();
1958        write(
1959            &root.join("manifests/skills.yaml"),
1960            r#"
1961schema_version: 1
1962skills:
1963  - id: tools.foo
1964    domain: tools
1965    source: core/skills/tools/foo
1966    products:
1967      codex:
1968        name: foo
1969        render_to: plugins/tools/skills/foo/SKILL.md
1970    required_clis: {}
1971"#,
1972        );
1973        write(
1974            &root.join("manifests/plugins.yaml"),
1975            "schema_version: 1\nplugins: []\n",
1976        );
1977        write(
1978            &root.join("manifests/product-capabilities.yaml"),
1979            PRODUCT_CAPS,
1980        );
1981        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
1982        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
1983
1984        let skill_src = root.join("core/skills/tools/foo");
1985        write(&skill_src.join("SKILL.md.tera"), "# foo\n");
1986        write(&skill_src.join("helper.sh"), "echo v1\n");
1987
1988        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
1989        let set = load_set(&source_root);
1990        write_product(&source_root, set, "codex").unwrap();
1991
1992        write(&skill_src.join("helper.sh"), "echo v2\n");
1993        let set = load_set(&source_root);
1994        let second = write_product(&source_root, set, "codex").unwrap();
1995        assert_eq!(
1996            second.rendered,
1997            vec!["tools.foo".to_string()],
1998            "sibling byte change should trigger a re-render (cache miss)",
1999        );
2000        let body = fs::read_to_string(root.join("build/codex/plugins/tools/skills/foo/helper.sh"))
2001            .unwrap();
2002        assert_eq!(body, "echo v2\n");
2003    }
2004
2005    #[test]
2006    fn write_product_runs_against_empty_skills_manifest() {
2007        // Real agent-runtime-kit ships skills.yaml empty in Plan 01.
2008        // Render must not blow up — it should produce an empty cache.
2009        let tmp = TempDir::new().unwrap();
2010        let root = tmp.path();
2011        write(
2012            &root.join("manifests/skills.yaml"),
2013            "schema_version: 1\nskills: []\n",
2014        );
2015        write(
2016            &root.join("manifests/plugins.yaml"),
2017            "schema_version: 1\nplugins: []\n",
2018        );
2019        write(
2020            &root.join("manifests/product-capabilities.yaml"),
2021            PRODUCT_CAPS,
2022        );
2023        write(&root.join("manifests/runtime-roots.yaml"), RUNTIME_ROOTS);
2024        write(&root.join("manifests/cli-tools.yaml"), CLI_TOOLS);
2025        let source_root = SourceRoot::from_arg_or_cwd(Some(root)).unwrap();
2026        let set = load_set(&source_root);
2027        let report = write_product(&source_root, set, "codex").unwrap();
2028        assert!(report.rendered.is_empty());
2029        assert!(report.cached.is_empty());
2030        assert!(report.skipped.is_empty());
2031        assert!(report.output_root.exists());
2032    }
2033}