Skip to main content

agentmux_srv/backend/
bundle_export.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Armory Bundle Format (ABF) exporter — Phase 1 of
5//! `docs/specs/REPORT_ARMORY_BUNDLE_STANDARD_RESEARCH_2026_07_16.md` /
6//! <https://docs.agentmux.ai/abf/>. Serializes a `db_bundles` row (the
7//! `Bundle` struct — table/UI say "Bundles", the type name predates the
8//! rename) plus its referenced skills into the ABF on-disk layout:
9//!
10//! ```text
11//! <bundle-slug>/
12//! ├── bundle.json
13//! ├── instructions/
14//! │   ├── AGENTS.md
15//! │   └── context/…
16//! └── skills/
17//!     └── <skill-slug>/
18//!         └── SKILL.md
19//! ```
20//!
21//! Pure functions only — no I/O, no Store access. Callers (the `bundle.export`
22//! RPC handler) own fetching the `Bundle` row and resolving its `skills`
23//! id-array into `Skill` rows before calling [`export_bundle`].
24//!
25//! **No `mcp/` or `accounts/`.** A bundle carries no MCP servers; they belong
26//! to Connectors (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1).
27//! `accounts/requirements.json` was inferred from those servers' `env` keys,
28//! so it went with them, as did the redaction of secrets out of their configs.
29//! An importer still reads both from older archives.
30
31use std::collections::{HashMap, HashSet};
32
33use serde::Serialize;
34use serde_json::{json, Value};
35
36use super::agent_config::{render_skill_md, unique_skill_slug, SKILL_TYPE_AGENT_SKILL};
37use super::storage::store::{derive_slug, Bundle};
38use super::storage::Skill;
39
40/// One file within an exported bundle, path relative to the bundle root
41/// (e.g. `"bundle.json"`, `"instructions/AGENTS.md"`).
42#[derive(Debug, Clone, Serialize, PartialEq, Eq, ts_rs::TS)]
43#[ts(export, export_to = "../../../frontend/types/rpc/")]
44pub struct BundleExportFile {
45    pub path: String,
46    pub content: String,
47}
48
49/// Result of exporting one bundle: every file to write, plus bookkeeping
50/// about anything that couldn't be represented in ABF and was left out
51/// (surfaced to the caller/UI rather than silently dropped).
52#[derive(Debug, Clone, Serialize)]
53pub struct BundleExport {
54    /// Filesystem-safe slug derived from the bundle's name — the
55    /// recommended root directory / zip base name.
56    pub root_slug: String,
57    pub files: Vec<BundleExportFile>,
58    /// Names of skills that were NOT exported because their `skill_type`
59    /// isn't `"agent-skill"` (ABF's `skills` component is Agent Skills
60    /// (SKILL.md) format specifically — AgentMux's proprietary
61    /// slash-command skills have no ABF representation, see Phase 0 /
62    /// `SKILL_TYPE_AGENT_SKILL`).
63    pub skipped_skills: Vec<String>,
64    /// Non-fatal problems encountered while exporting: malformed source
65    /// JSON (`context_files`, `instructions_by_provider`) that had to be treated as
66    /// empty, or a context-file path that collided with an earlier one
67    /// after normalization and was skipped rather than silently
68    /// overwriting it. Never blocks the export — surfaced to the
69    /// caller/UI instead of being silently swallowed, since a backup tool
70    /// losing data without saying so defeats its own purpose (reagent P1,
71    /// PR #2333).
72    pub warnings: Vec<String>,
73}
74
75// `pub(crate)` so `bundle_validate.rs` can parse `context_files` the exact
76// same way export does — a single shared shape means the two can never
77// silently disagree on what a context-file entry looks like.
78#[derive(Debug, serde::Deserialize, Default)]
79pub(crate) struct ContextFileEntry {
80    #[serde(default)]
81    pub(crate) path: String,
82    #[serde(default)]
83    pub(crate) content: String,
84}
85
86/// Validate a relative path is safe to place under a bundle export/import
87/// root: non-empty, not absolute, no drive letter, no `..` traversal
88/// component. Pure string validation (no filesystem access) so
89/// [`export_bundle`] can stay a pure function. Returns `None` for anything
90/// that fails; callers skip that entry.
91///
92/// `pub(crate)` (not private) so `bundle_import.rs` can reuse the exact
93/// same check on the way IN — an untrusted `.abf` file needs this defense
94/// at least as much as export needs it on the way out, and a single shared
95/// implementation means the two can never drift apart on what counts as
96/// safe (see `docs/specs/SPEC_ABF_V0_1_SINGLE_FILE_AND_IMPORTER_2026_08_01.md`
97/// §4.3.4).
98pub(crate) fn sanitize_context_relative_path(path: &str) -> Option<String> {
99    if path.is_empty() {
100        return None;
101    }
102    let normalized = path.replace('\\', "/");
103    if normalized.starts_with('/') || normalized.contains(':') {
104        return None;
105    }
106    let mut parts = Vec::new();
107    for component in normalized.split('/') {
108        match component {
109            "" | "." => continue,
110            ".." => return None,
111            other => parts.push(other),
112        }
113    }
114    if parts.is_empty() {
115        return None;
116    }
117    Some(parts.join("/"))
118}
119
120/// Export a bundle + its already-resolved components into the ABF layout.
121///
122/// **This function does no store lookups and reads no component column off
123/// `bundle`.** `skills` must be resolved by the caller from
124/// `db_bundle_skills_ref`, which is authoritative for what a bundle contains
125/// (Phase 0b of SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md).
126/// Callers skip ids that failed to resolve before calling; this does not
127/// distinguish "missing" from "not passed".
128pub fn export_bundle(bundle: &Bundle, skills: &[Skill]) -> BundleExport {
129    let root_slug = derive_slug(&bundle.name);
130    let mut files = Vec::new();
131    let mut skipped_skills = Vec::new();
132    let mut warnings = Vec::new();
133
134    let mut manifest_instructions: Vec<String> = Vec::new();
135    let mut manifest_skills: Vec<String> = Vec::new();
136
137    // ------------------------------------------------------------------
138    // instructions/AGENTS.md (default) + instructions/<provider>/AGENTS.md
139    // (ABF v0.2 §2.2) + instructions/context/*
140    // ------------------------------------------------------------------
141    if !bundle.instructions.trim().is_empty() {
142        files.push(BundleExportFile {
143            path: "instructions/AGENTS.md".to_string(),
144            content: bundle.instructions.clone(),
145        });
146        manifest_instructions.push("instructions/AGENTS.md".to_string());
147    }
148
149    let instructions_by_provider: HashMap<String, String> = parse_json_field_or_warn(
150        &bundle.instructions_by_provider,
151        "instructions_by_provider",
152        &mut warnings,
153    );
154    // manifest_instructions_by_provider preserves insertion order isn't
155    // required (the manifest's own object key order is not meaningful), but
156    // BTreeMap gives deterministic output ordering across export calls,
157    // which matters for reproducible zip byte content.
158    let mut manifest_instructions_by_provider: std::collections::BTreeMap<String, Vec<String>> =
159        std::collections::BTreeMap::new();
160    // reagent P2, PR #2523: two distinct raw keys (e.g. "claude" and
161    // "./claude") can sanitize to the SAME output path — without a
162    // collision check, both push a BundleExportFile at the identical path
163    // (last one written silently wins, nondeterministically, since
164    // HashMap iteration order isn't guaranteed) and the manifest lists
165    // that path twice. Iterate raw keys sorted first (deterministic which
166    // one "wins" a collision, not just which one happens to iterate
167    // last), and skip + warn on the second and further collisions rather
168    // than silently duplicating.
169    let mut sorted_providers: Vec<(&String, &String)> = instructions_by_provider.iter().collect();
170    sorted_providers.sort_by(|a, b| a.0.cmp(b.0));
171    let mut seen_safe_providers: HashSet<String> = HashSet::new();
172    for (provider, content) in sorted_providers {
173        if content.trim().is_empty() {
174            continue;
175        }
176        // Provider keys land directly in a file path — sanitize the same
177        // way every other manifest-adjacent path segment in this module
178        // is, since a key that arrived via import (bundle_import.rs stores
179        // whatever key string a manifest declared, unvalidated against the
180        // known provider list) could otherwise smuggle a traversal segment
181        // into instructions/<provider>/AGENTS.md on a later re-export.
182        let Some(safe_provider) = sanitize_context_relative_path(provider) else {
183            warnings.push(format!(
184                "instructions_by_provider: \"{provider}\" is not a safe path segment; skipped"
185            ));
186            continue;
187        };
188        if !seen_safe_providers.insert(safe_provider.clone()) {
189            warnings.push(format!(
190                "instructions_by_provider: \"{provider}\" normalizes to the same path as an earlier key (instructions/{safe_provider}/AGENTS.md); skipped to avoid overwriting it"
191            ));
192            continue;
193        }
194        let out_path = format!("instructions/{safe_provider}/AGENTS.md");
195        files.push(BundleExportFile {
196            path: out_path.clone(),
197            content: content.clone(),
198        });
199        manifest_instructions_by_provider
200            .entry(safe_provider)
201            .or_default()
202            .push(out_path);
203    }
204
205    let context_files: Vec<ContextFileEntry> = parse_json_field_or_warn(
206        &bundle.context_files,
207        "context_files",
208        &mut warnings,
209    );
210    // Compared case-INSENSITIVELY (stores the lowercased form) because the
211    // most common export/extract targets (Windows, macOS default) have
212    // case-insensitive filesystems -- "Docs/A.md" and "docs/a.md" collide (comment-hygiene: allow)
213    // on extraction there even though they're distinct paths byte-for-byte
214    // (reagent P2, PR #2333). The path actually written to `files` keeps
215    // its original case; only the collision check is case-folded.
216    let mut used_context_paths: HashSet<String> = HashSet::new();
217    for entry in context_files {
218        if let Some(safe_path) = sanitize_context_relative_path(&entry.path) {
219            let out_path = format!("instructions/context/{safe_path}");
220            // Two distinct source paths can normalize to the same output
221            // (e.g. "docs/a.md" and "docs/./a.md", or a case-only (comment-hygiene: allow)
222            // difference) -- without this check the second silently
223            // overwrites the first's `files` entry, and the zip archive
224            // ends up with the same duplicate risk (reagent P2, PR #2333).
225            if !used_context_paths.insert(out_path.to_lowercase()) {
226                warnings.push(format!(
227                    "context_files: \"{}\" normalizes to the same path as an \
228                     earlier entry ({out_path}); skipped to avoid overwriting it",
229                    entry.path
230                ));
231                continue;
232            }
233            manifest_instructions.push(out_path.clone());
234            files.push(BundleExportFile {
235                path: out_path,
236                content: entry.content,
237            });
238        } else {
239            // Rejected by sanitization (absolute path, ".." traversal, a
240            // drive-letter colon, or an empty path) -- must warn the same
241            // way the collision case right above does, or a backup export
242            // can silently lose a context file with no signal anywhere in
243            // the returned `warnings` (Codex + reagent P2, PR #2333 --
244            // flagged twice, unaddressed in the prior push).
245            warnings.push(format!(
246                "context_files: \"{}\" is not a safe relative path (absolute, \
247                 contains \"..\", a drive letter, or empty); skipped",
248                entry.path
249            ));
250        }
251    }
252
253    // ------------------------------------------------------------------
254    // skills/<slug>/SKILL.md — Agent Skills format only (see doc comment)
255    // ------------------------------------------------------------------
256    let mut used_skill_slugs: HashSet<String> = HashSet::new();
257    for skill in skills {
258        if skill.skill_type != SKILL_TYPE_AGENT_SKILL {
259            skipped_skills.push(skill.name.clone());
260            continue;
261        }
262        if skill.content.is_empty() {
263            skipped_skills.push(skill.name.clone());
264            continue;
265        }
266        let slug = unique_skill_slug(&skill.name, &mut used_skill_slugs);
267        files.push(BundleExportFile {
268            path: format!("skills/{slug}/SKILL.md"),
269            content: render_skill_md(&slug, &skill.description, &skill.content),
270        });
271        // Manifest references the skill's DIRECTORY, not the SKILL.md file
272        // inside it -- per the ABF spec's own on-disk layout example
273        // (`"skills": ["skills/deploy-checklist"]`), which lets an importer
274        // locate SKILL.md plus any optional scripts/references/assets
275        // alongside it (Codex P1, PR #2325).
276        manifest_skills.push(format!("skills/{slug}"));
277    }
278
279    let mut components = serde_json::Map::new();
280    // ABF v0.2 §2.2: components.instructions is always the keyed-object
281    // shape on export ("default" plus zero or more provider variants) —
282    // the importer accepts both this and the v0.1 flat-array shape for
283    // backward compatibility, but new exports only ever emit the v0.2
284    // shape.
285    if !manifest_instructions.is_empty() || !manifest_instructions_by_provider.is_empty() {
286        let mut instructions_obj = serde_json::Map::new();
287        if !manifest_instructions.is_empty() {
288            instructions_obj.insert("default".to_string(), json!(manifest_instructions));
289        }
290        for (provider, paths) in &manifest_instructions_by_provider {
291            instructions_obj.insert(provider.clone(), json!(paths));
292        }
293        components.insert("instructions".to_string(), Value::Object(instructions_obj));
294    }
295    if !manifest_skills.is_empty() {
296        components.insert("skills".to_string(), json!(manifest_skills));
297    }
298
299
300    // Who the bundle was made for: a hint, never enforced (ABF v0.3,
301    // SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §2.1). The row's `model`
302    // has always held a vendor, so that's its name here. Omitted when unset.
303    let mut suggested_for = serde_json::Map::new();
304    if !bundle.provider.is_empty() {
305        suggested_for.insert("provider".into(), json!(bundle.provider));
306    }
307    if !bundle.model.is_empty() {
308        suggested_for.insert("vendor".into(), json!(bundle.model));
309    }
310    let mut manifest = json!({
311        // The ABF FORMAT version (Agent Bundle Format v0.3), distinct from
312        // the "version" field below (the bundle's own content version).
313        "$schema": crate::backend::bundle_import::SCHEMA_V0_3,
314        "name": root_slug,
315        // Bundles have no native version concept (no `version` column) --
316        // ABF requires one, so this is an export-time default the user is
317        // expected to bump before actually publishing the bundle anywhere.
318        "version": "0.1.0",
319        "description": bundle.description,
320        "components": Value::Object(components),
321        "metadata": {},
322    });
323    if !suggested_for.is_empty() {
324        manifest["suggestedFor"] = Value::Object(suggested_for);
325    }
326    files.push(BundleExportFile {
327        path: crate::backend::bundle_import::MANIFEST_FILE.to_string(),
328        content: serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| "{}".to_string()),
329    });
330
331    BundleExport {
332        root_slug,
333        files,
334        skipped_skills,
335        warnings,
336    }
337}
338
339/// Parse a `db_bundles` JSON column (`context_files`,
340/// `instructions_by_provider`), treating a blank/whitespace-only value as
341/// "genuinely no data" (not an error) but pushing a warning to `warnings`
342/// for anything non-blank that fails to parse, rather than silently
343/// discarding it via `unwrap_or_default()` — an export that quietly loses
344/// data defeats its own backup/portability purpose (#2333).
345pub(crate) fn parse_json_field_or_warn<T: serde::de::DeserializeOwned + Default>(
346    raw: &str,
347    field_name: &str,
348    warnings: &mut Vec<String>,
349) -> T {
350    if raw.trim().is_empty() {
351        return T::default();
352    }
353    match serde_json::from_str(raw) {
354        Ok(v) => v,
355        Err(e) => {
356            warnings.push(format!("{field_name}: malformed JSON, treated as empty ({e})"));
357            T::default()
358        }
359    }
360}
361
362/// Pack an export's files into a zip archive in memory. First `ZipWriter`
363/// usage in this workspace — `tool_store.rs` only ever reads zips. No
364/// filesystem access: writes into an in-memory buffer, so this stays as
365/// side-effect-free as the exporter itself (the RPC handler decides what
366/// to do with the resulting bytes).
367pub fn zip_bundle_export(export: &BundleExport) -> Result<Vec<u8>, String> {
368    use std::io::Write;
369    use zip::write::SimpleFileOptions;
370
371    let mut buf = std::io::Cursor::new(Vec::new());
372    let mut writer = zip::ZipWriter::new(&mut buf);
373    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
374
375    for file in &export.files {
376        let zip_path = format!("{}/{}", export.root_slug, file.path);
377        writer
378            .start_file(zip_path, options)
379            .map_err(|e| format!("zip_bundle_export: {e}"))?;
380        writer
381            .write_all(file.content.as_bytes())
382            .map_err(|e| format!("zip_bundle_export: {e}"))?;
383    }
384
385    writer
386        .finish()
387        .map_err(|e| format!("zip_bundle_export: {e}"))?;
388    Ok(buf.into_inner())
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn make_bundle(instructions: &str, context_files: &str, mcp_servers: &str, skills: &str) -> Bundle {
396        Bundle {
397            id: "bundle-1".to_string(),
398            name: "Backend Dev Bundle".to_string(),
399            description: "Backend dev conventions".to_string(),
400            is_blank: false,
401            is_global: false,
402            provider: String::new(),
403            model: String::new(),
404            instructions: instructions.to_string(),
405            instructions_by_provider: "{}".to_string(),
406            context_files: context_files.to_string(),
407            mcp_servers: mcp_servers.to_string(),
408            skills: skills.to_string(),
409            sort_order: 0,
410            created_at: 1_700_000_000_000,
411            updated_at: 1_700_000_000_000,
412            is_system: false,
413        }
414    }
415
416    fn make_agent_skill(name: &str) -> Skill {
417        Skill {
418            id: format!("skill-{name}"),
419            name: name.to_string(),
420            trigger: String::new(),
421            skill_type: "agent-skill".to_string(),
422            description: format!("{name} description"),
423            content: format!("{name} content"),
424            is_global: true,
425            created_at: 1_700_000_000_000,
426            updated_at: 1_700_000_000_000,
427        }
428    }
429
430    #[test]
431    fn exports_instructions_as_agents_md() {
432        let bundle = make_bundle("Follow repo conventions.", "[]", "[]", "[]");
433        let export = export_bundle(&bundle, &[]);
434        let f = export.files.iter().find(|f| f.path == "instructions/AGENTS.md").unwrap();
435        assert_eq!(f.content, "Follow repo conventions.");
436    }
437
438    #[test]
439    fn exports_context_files_under_instructions_context() {
440        let context_files = r#"[{"path":"docs/readme.md","content":"Readme heading"}]"#;
441        let bundle = make_bundle("", context_files, "[]", "[]");
442        let export = export_bundle(&bundle, &[]);
443        let f = export
444            .files
445            .iter()
446            .find(|f| f.path == "instructions/context/docs/readme.md")
447            .expect("expected instructions/context/docs/readme.md");
448        assert_eq!(f.content, "Readme heading");
449    }
450
451    #[test]
452    fn rejects_path_traversal_in_context_files() {
453        let context_files = r#"[{"path":"../../etc/passwd","content":"evil"}]"#;
454        let bundle = make_bundle("", context_files, "[]", "[]");
455        let export = export_bundle(&bundle, &[]);
456        assert!(export.files.iter().all(|f| !f.content.contains("evil")));
457        assert!(!export.files.iter().any(|f| f.path.contains("..")));
458        // Codex + reagent P2, PR #2333: a rejected entry must not just
459        // vanish -- an export used as a backup can silently lose a context
460        // file with no signal anywhere in `warnings` otherwise.
461        assert!(
462            export.warnings.iter().any(|w| w.contains("../../etc/passwd")),
463            "expected a warning naming the rejected path, got: {:?}",
464            export.warnings
465        );
466    }
467
468    #[test]
469    fn exports_agent_skill_format_skills_and_skips_prompt_format() {
470        let bundle = make_bundle("", "[]", "[]", r#"["skill-a","skill-b"]"#);
471        let mut prompt_skill = make_agent_skill("Slash Skill");
472        prompt_skill.skill_type = "prompt".to_string();
473        let skills = vec![make_agent_skill("Deploy Checklist"), prompt_skill];
474
475        let export = export_bundle(&bundle, &skills);
476        assert!(export
477            .files
478            .iter()
479            .any(|f| f.path == "skills/deploy-checklist/SKILL.md"));
480        assert!(!export.files.iter().any(|f| f.path.contains("slash-skill")));
481        assert_eq!(export.skipped_skills, vec!["Slash Skill".to_string()]);
482    }
483
484    #[test]
485    fn malformed_context_files_json_warns_instead_of_silently_dropping_data() {
486        // reagent P1, PR #2333: previously unwrap_or_default() silently
487        // treated malformed context_files as empty, with no signal to the
488        // caller that data was lost -- defeats the exporter's stated
489        // backup/portability guarantee.
490        let bundle = make_bundle("", "{not valid json", "[]", "[]");
491        let export = export_bundle(&bundle, &[]);
492        assert!(
493            export.warnings.iter().any(|w| w.contains("context_files") && w.contains("malformed")),
494            "expected a warning about malformed context_files, got: {:?}",
495            export.warnings
496        );
497    }
498
499    #[test]
500    fn blank_context_files_produce_no_warning() {
501        // A genuinely empty/unset field is not an error -- must not warn.
502        let bundle = make_bundle("", "", "", "[]");
503        let export = export_bundle(&bundle, &[]);
504        assert!(export.warnings.is_empty(), "blank fields must not warn: {:?}", export.warnings);
505    }
506
507    #[test]
508    fn parse_json_field_or_warn_direct_unit_test() {
509        // PR #2333: `bundle.export`'s RPC handler used to parse the inline
510        // `bundle.skills` column with this exact helper (until #3152 made the
511        // ref tables authoritative), which previously had the same
512        // unwrap_or_default() silent-loss bug already fixed here for
513        // context_files.
514        let mut warnings = Vec::new();
515        let blank: Vec<String> = parse_json_field_or_warn("", "skills", &mut warnings);
516        assert!(blank.is_empty());
517        assert!(warnings.is_empty(), "blank must not warn");
518
519        let malformed: Vec<String> = parse_json_field_or_warn("not json", "skills", &mut warnings);
520        assert!(malformed.is_empty());
521        assert_eq!(warnings.len(), 1);
522        assert!(warnings[0].contains("skills") && warnings[0].contains("malformed"));
523
524        let mut warnings2 = Vec::new();
525        let valid: Vec<String> = parse_json_field_or_warn(r#"["a","b"]"#, "skills", &mut warnings2);
526        assert_eq!(valid, vec!["a".to_string(), "b".to_string()]);
527        assert!(warnings2.is_empty());
528    }
529
530    #[test]
531    fn colliding_context_file_paths_are_deduped_with_a_warning() {
532        // reagent P2, PR #2333: two distinct source paths that normalize to
533        // the same output (e.g. a redundant "./" component) previously
534        // silently overwrote one `files` entry with the other.
535        let context_files = r#"[
536            {"path":"docs/a.md","content":"first"},
537            {"path":"docs/./a.md","content":"second, would silently clobber the first"}
538        ]"#;
539        let bundle = make_bundle("", context_files, "[]", "[]");
540        let export = export_bundle(&bundle, &[]);
541
542        let matches: Vec<_> = export
543            .files
544            .iter()
545            .filter(|f| f.path == "instructions/context/docs/a.md")
546            .collect();
547        assert_eq!(matches.len(), 1, "must not produce duplicate file entries for the same path");
548        assert_eq!(matches[0].content, "first", "the first entry must win, not be silently overwritten");
549        assert!(
550            export.warnings.iter().any(|w| w.contains("docs/./a.md")),
551            "expected a warning naming the skipped duplicate, got: {:?}",
552            export.warnings
553        );
554    }
555
556    #[test]
557    fn colliding_context_file_paths_case_insensitive() {
558        // reagent P2, PR #2333: "Docs/A.md" and "docs/a.md" are distinct (comment-hygiene: allow)
559        // byte-for-byte but collide on extraction on the most common
560        // export targets (Windows, macOS default case-insensitive
561        // filesystems) -- must be caught the same way an exact-match
562        // collision is.
563        let context_files = r#"[
564            {"path":"Docs/A.md","content":"first"},
565            {"path":"docs/a.md","content":"second, would collide on a case-insensitive filesystem"}
566        ]"#;
567        let bundle = make_bundle("", context_files, "[]", "[]");
568        let export = export_bundle(&bundle, &[]);
569
570        let matches: Vec<_> = export
571            .files
572            .iter()
573            .filter(|f| f.path.to_lowercase() == "instructions/context/docs/a.md")
574            .collect();
575        assert_eq!(matches.len(), 1, "must not produce case-only-different duplicate entries");
576        assert_eq!(matches[0].content, "first");
577        assert!(export.warnings.iter().any(|w| w.contains("docs/a.md")));
578    }
579
580    #[test]
581    fn manifest_lists_every_component_and_validates_as_json() {
582        let bundle = make_bundle(
583            "Instructions",
584            r#"[{"path":"a.md","content":"A"}]"#,
585            r#"[{"name":"github","env":{"GITHUB_TOKEN":""}}]"#,
586            r#"["skill-a"]"#,
587        );
588        let export = export_bundle(&bundle, &[make_agent_skill("Deploy")]);
589        let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
590        let manifest: Value = serde_json::from_str(&manifest_file.content).expect("bundle.json must be valid JSON");
591        assert_eq!(manifest["name"], "backend-dev-bundle");
592        // ABF v0.2 §2.2: components.instructions is the keyed-object shape
593        // ("default" + provider variants), not a flat array.
594        assert!(manifest["components"]["instructions"]["default"].as_array().unwrap().len() == 2);
595        assert!(manifest["components"]["skills"].as_array().unwrap().len() == 1);
596    }
597
598    /// A bundle carries no MCP servers, so a leftover inline column exports
599    /// no `mcp/` files, no `mcpServers` and no inferred `accounts/`
600    /// (SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md §3.1).
601    #[test]
602    fn exports_no_mcp_servers_and_no_inferred_requirements() {
603        let bundle = make_bundle("", "[]", r#"[{"name":"github","env":{"GITHUB_TOKEN":"ghp_x"}}]"#, "[]");
604        let export = export_bundle(&bundle, &[]);
605        assert!(!export.files.iter().any(|f| f.path.starts_with("mcp/") || f.path.starts_with("accounts/")));
606        let manifest: Value = serde_json::from_str(&export.files.iter().find(|f| f.path == "bundle.json").unwrap().content).unwrap();
607        assert!(manifest["components"].get("mcpServers").is_none());
608        assert!(manifest["components"].get("accounts").is_none());
609        assert!(!export.files.iter().any(|f| f.content.contains("ghp_x")));
610    }
611
612    #[test]
613    fn exports_a_provider_scoped_instruction_variant() {
614        let mut bundle = make_bundle("Default instructions.", "[]", "[]", "[]");
615        bundle.instructions_by_provider =
616            r#"{"claude":"Claude-specific override.","codex":"Codex-specific override."}"#.to_string();
617        let export = export_bundle(&bundle, &[]);
618
619        let claude_file = export.files.iter().find(|f| f.path == "instructions/claude/AGENTS.md")
620            .expect("expected instructions/claude/AGENTS.md");
621        assert_eq!(claude_file.content, "Claude-specific override.");
622        let codex_file = export.files.iter().find(|f| f.path == "instructions/codex/AGENTS.md")
623            .expect("expected instructions/codex/AGENTS.md");
624        assert_eq!(codex_file.content, "Codex-specific override.");
625
626        let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
627        let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
628        assert_eq!(manifest["components"]["instructions"]["default"], json!(["instructions/AGENTS.md"]));
629        assert_eq!(manifest["components"]["instructions"]["claude"], json!(["instructions/claude/AGENTS.md"]));
630        assert_eq!(manifest["components"]["instructions"]["codex"], json!(["instructions/codex/AGENTS.md"]));
631    }
632
633    #[test]
634    fn a_blank_provider_variant_is_omitted_entirely() {
635        // An empty-string variant (e.g. left over from a UI field that was
636        // added then cleared) must not produce an empty instructions file
637        // or an empty manifest entry.
638        let mut bundle = make_bundle("Default.", "[]", "[]", "[]");
639        bundle.instructions_by_provider = r#"{"claude":"   "}"#.to_string();
640        let export = export_bundle(&bundle, &[]);
641        assert!(!export.files.iter().any(|f| f.path.starts_with("instructions/claude/")));
642        let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
643        let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
644        assert!(manifest["components"]["instructions"].get("claude").is_none());
645    }
646
647    #[test]
648    fn colliding_provider_keys_after_sanitization_do_not_overwrite_each_other() {
649        // reagent P2, PR #2523: "claude" and "./claude" both sanitize to
650        // the same output path — the second one must be skipped with a
651        // warning, not silently overwrite the first (or worse, produce a
652        // manifest listing the same path twice with ambiguous content).
653        let mut bundle = make_bundle("Default.", "[]", "[]", "[]");
654        bundle.instructions_by_provider =
655            r#"{"claude":"First.","./claude":"Second."}"#.to_string();
656        let export = export_bundle(&bundle, &[]);
657
658        let claude_files: Vec<_> = export.files.iter().filter(|f| f.path == "instructions/claude/AGENTS.md").collect();
659        assert_eq!(claude_files.len(), 1, "must not produce two files at the same path");
660        // "./claude" sorts before "claude" byte-wise ('.' < 'c'), so it's
661        // processed first and wins; "claude" is the one skipped as a
662        // duplicate. The exact winner is an implementation detail — what
663        // matters is that it's deterministic and there's only one.
664        assert_eq!(claude_files[0].content, "Second.");
665
666        let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
667        let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
668        assert_eq!(
669            manifest["components"]["instructions"]["claude"],
670            json!(["instructions/claude/AGENTS.md"]),
671            "must not list the same path twice"
672        );
673        assert!(export.warnings.iter().any(|w| w.contains("normalizes to the same path")));
674    }
675
676    #[test]
677    fn manifest_references_the_skill_directory_not_the_skill_md_file() {
678        // Codex P1, PR #2325: the ABF spec's manifest example references
679        // "skills/<slug>" (the directory), not "skills/<slug>/SKILL.md".
680        let bundle = make_bundle("", "[]", "[]", r#"["skill-a"]"#);
681        let export = export_bundle(&bundle, &[make_agent_skill("Deploy Checklist")]);
682        let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
683        let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
684        let skills = manifest["components"]["skills"].as_array().unwrap();
685        assert_eq!(skills, &vec![json!("skills/deploy-checklist")]);
686        // The actual file on disk still lives at the nested SKILL.md path.
687        assert!(export.files.iter().any(|f| f.path == "skills/deploy-checklist/SKILL.md"));
688    }
689
690    #[test]
691    fn writes_an_abf_v0_3_manifest_with_a_suggested_for_hint() {
692        let mut bundle = make_bundle("Be concise.", "[]", "[]", "[]");
693        bundle.provider = "codex".into();
694        bundle.model = "openai".into();
695        let export = export_bundle(&bundle, &[]);
696        let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").expect("bundle.json");
697        assert!(!export.files.iter().any(|f| f.path == "armory.json"));
698        let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
699        assert_eq!(manifest["$schema"], "https://docs.agentmux.ai/schemas/agent-bundle/v0.3/bundle.schema.json");
700        assert_eq!(manifest["suggestedFor"], json!({ "provider": "codex", "vendor": "openai" }));
701        assert!(manifest.get("provider").is_none() && manifest.get("model").is_none());
702    }
703
704    #[test]
705    fn omits_the_hint_when_the_bundle_has_none() {
706        let export = export_bundle(&make_bundle("x", "[]", "[]", "[]"), &[]);
707        let manifest: Value = serde_json::from_str(&export.files.iter().find(|f| f.path == "bundle.json").unwrap().content).unwrap();
708        assert!(manifest.get("suggestedFor").is_none());
709    }
710
711    #[test]
712    fn a_v0_3_export_round_trips_through_import() {
713        let mut bundle = make_bundle("Be concise.", "[]", "[]", "[]");
714        bundle.provider = "gemini".into();
715        bundle.model = "google".into();
716        let export = export_bundle(&bundle, &[]);
717        let files: Vec<crate::backend::bundle_import::BundleImportFile> = export
718            .files
719            .iter()
720            .map(|f| crate::backend::bundle_import::BundleImportFile { path: f.path.clone(), content: f.content.clone() })
721            .collect();
722        let parsed = crate::backend::bundle_import::parse_bundle_import(&files).unwrap();
723        assert_eq!((parsed.provider.as_str(), parsed.model.as_str()), ("gemini", "google"));
724        assert!(parsed.instructions.contains("Be concise."));
725        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
726    }
727
728    #[test]
729    fn empty_bundle_still_produces_a_valid_manifest() {
730        let bundle = make_bundle("", "[]", "[]", "[]");
731        let export = export_bundle(&bundle, &[]);
732        // bundle.json is always written, even for a fully empty bundle.
733        assert_eq!(export.files.len(), 1);
734        let manifest: Value = serde_json::from_str(&export.files[0].content).unwrap();
735        assert_eq!(manifest["components"], json!({}));
736    }
737
738    #[test]
739    fn zip_bundle_export_produces_a_valid_archive_with_all_files() {
740        let bundle = make_bundle("Instructions here", "[]", "[]", "[]");
741        let export = export_bundle(&bundle, &[]);
742        let zip_bytes = zip_bundle_export(&export).expect("zip should succeed");
743
744        let cursor = std::io::Cursor::new(zip_bytes);
745        let mut archive = zip::ZipArchive::new(cursor).expect("valid zip archive");
746        assert_eq!(archive.len(), export.files.len());
747
748        let mut found = false;
749        for i in 0..archive.len() {
750            let file = archive.by_index(i).unwrap();
751            if file.name() == "backend-dev-bundle/bundle.json" {
752                found = true;
753            }
754        }
755        assert!(found, "expected backend-dev-bundle/bundle.json in the archive");
756    }
757}