Skip to main content

agentmux_srv/backend/
bundle_import.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Armory Bundle Format (ABF) importer — Phase 2 of
5//! `docs/specs/SPEC_ABF_V0_1_SINGLE_FILE_AND_IMPORTER_2026_08_01.md`.
6//! Inverse of `bundle_export.rs`: takes a bundle's files (either a raw
7//! `[{path, content}]` list or an unpacked `.abf` zip archive) and
8//! validates + parses them into data ready for the `bundle.import` RPC
9//! handler to write to the Store.
10//!
11//! Pure functions only — no I/O, no Store access, no account resolution
12//! (account resolution needs `db_accounts`, which only the RPC handler has
13//! access to; see the spec's §4.5). Mirrors `bundle_export.rs`'s shape and
14//! quality bar deliberately: the same "warn, don't silently drop" and
15//! "reject the whole thing on structural failure, but never partially
16//! import on a per-entry problem" philosophy, since the two modules are
17//! meant to round-trip against each other.
18
19use std::collections::{HashMap, HashSet};
20
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24use super::bundle_export::sanitize_context_relative_path;
25
26/// Bounds how many warnings a parse call accumulates, and how long each
27/// individual warning string may be — Phase 3 spec
28/// (`SPEC_ABF_IMPORT_UI_PHASE3_2026_08_02.md`) §3.1, round 11: the cap must
29/// live at the point warnings are actually PRODUCED, not only at a later
30/// RPC-response-serialization boundary, or a hostile archive can still
31/// force the parser itself to build an unbounded `Vec<String>` in memory
32/// before any cap ever runs. `unbounded()` preserves today's existing
33/// `bundle.import` route's real, already-shipped behavior exactly — this is
34/// a capability the parser gains, not a behavior change forced onto Phase 2's
35/// existing caller.
36#[derive(Debug, Clone, Copy)]
37pub struct WarningBudget {
38    max_count: Option<usize>,
39    max_len: Option<usize>,
40}
41
42impl WarningBudget {
43    pub fn unbounded() -> Self {
44        Self { max_count: None, max_len: None }
45    }
46
47    pub fn bounded(max_count: usize, max_len: usize) -> Self {
48        Self { max_count: Some(max_count), max_len: Some(max_len) }
49    }
50}
51
52/// Accumulates warnings against a [`WarningBudget`]. Exposes `.push(String)`
53/// so every existing `warnings.push(format!(...))` call site in this module
54/// keeps working unchanged after its binding's type changes from
55/// `Vec<String>` to this — only construction (`WarningSink::new`) and
56/// extraction (`.into_vec()`) differ.
57#[derive(Debug, Clone)]
58struct WarningSink {
59    budget: WarningBudget,
60    warnings: Vec<String>,
61    dropped: usize,
62}
63
64impl WarningSink {
65    fn new(budget: WarningBudget) -> Self {
66        Self { budget, warnings: Vec::new(), dropped: 0 }
67    }
68
69    fn push(&mut self, message: String) {
70        if let Some(max_count) = self.budget.max_count {
71            if self.warnings.len() >= max_count {
72                self.dropped += 1;
73                return;
74            }
75        }
76        let message = match self.budget.max_len {
77            Some(max_len) => truncate_display(&message, max_len),
78            None => message,
79        };
80        self.warnings.push(message);
81    }
82
83    fn into_vec(mut self) -> Vec<String> {
84        if self.dropped > 0 {
85            self.warnings.push(format!("... {} more warning(s) not shown", self.dropped));
86        }
87        self.warnings
88    }
89}
90
91/// Fixed-character truncation shared by every bounded-display projection
92/// this module (and its RPC callers, Phase 3 spec §3.1/§3.2) define —
93/// stated once so `preview` and `commit` always apply IDENTICAL bounds to
94/// the equivalent field, rather than independent per-endpoint copies
95/// drifting apart (the exact class of gap codex found and re-found across
96/// rounds 9 and 12).
97pub fn truncate_display(s: &str, max_chars: usize) -> String {
98    if s.chars().count() <= max_chars {
99        s.to_string()
100    } else {
101        let mut truncated: String = s.chars().take(max_chars).collect();
102        truncated.push_str("...");
103        truncated
104    }
105}
106
107/// Cap on `instructions_preview` (Phase 3 spec §3.1, round 5) — generous for
108/// a glance-and-decide preview; bounds worst-case JSON-escaped response size
109/// to roughly 300 KB rather than the ~300 MB a maximally-adversarial,
110/// unbounded `instructions` string could otherwise force.
111pub const MAX_INSTRUCTIONS_PREVIEW_CHARS: usize = 50_000;
112
113/// Fixed-character display cap shared by every "meant to be short" bounded
114/// field this spec defines: skill `description`/`slug` (rounds 10/12),
115/// requirement `id`/`provider`/`env` (round 11), context-file
116/// `display_path` (round 13), and bundle `description` (round 12 self-audit).
117pub const MAX_DISPLAY_FIELD_CHARS: usize = 300;
118
119/// Fixed-character display cap for an MCP server's projected `name`/
120/// `command` (Phase 3 spec §3.1, round 7) — smaller than
121/// [`MAX_DISPLAY_FIELD_CHARS`] since these are meant to be short
122/// identifiers/executable names, not free-form text.
123pub const MAX_MCP_DISPLAY_FIELD_CHARS: usize = 200;
124
125/// Bounds `instructions_preview` for the RPC response — returns
126/// `(preview, truncated, total_chars)`. The full, untruncated `instructions`
127/// value is unaffected; this only bounds what's echoed back for display
128/// (Phase 3 spec §3.1, rounds 5 and 8).
129pub fn bounded_instructions_preview(instructions: &str) -> (String, bool, usize) {
130    let total_chars = instructions.chars().count();
131    if total_chars <= MAX_INSTRUCTIONS_PREVIEW_CHARS {
132        (instructions.to_string(), false, total_chars)
133    } else {
134        (instructions.chars().take(MAX_INSTRUCTIONS_PREVIEW_CHARS).collect(), true, total_chars)
135    }
136}
137
138/// Bounded `{name, command}` projection of an MCP server's full `config`
139/// (Phase 3 spec §3.1, round 7) — the full `config` is never returned in
140/// preview/commit responses, only this small, defensively-extracted
141/// projection. Falls back to `null` for either field when absent or not a
142/// string, since MCP JSON has no required shape (§3.0).
143pub fn mcp_server_display(config: &Value) -> Value {
144    let field = |key: &str| -> Value {
145        match config.get(key).and_then(|v| v.as_str()) {
146            Some(s) => Value::String(truncate_display(s, MAX_MCP_DISPLAY_FIELD_CHARS)),
147            None => Value::Null,
148        }
149    };
150    serde_json::json!({ "name": field("name"), "command": field("command") })
151}
152
153/// Every parsed skill slug that appears more than once across the WHOLE
154/// bundle — the `"duplicate_in_bundle"` collision pass (Phase 3 spec §3.1,
155/// codex P1 round 2), computed independently of any external global-catalog
156/// state so it's pure and directly testable.
157pub fn duplicate_in_bundle_slugs(skills: &[ParsedSkill]) -> HashSet<String> {
158    let mut seen: HashSet<&str> = HashSet::new();
159    let mut dupes: HashSet<String> = HashSet::new();
160    for skill in skills {
161        if !seen.insert(skill.slug.as_str()) {
162            dupes.insert(skill.slug.clone());
163        }
164    }
165    dupes
166}
167
168/// `"none"` / `"name_conflict"` / `"duplicate_in_bundle"` — Phase 3 spec
169/// §3.1's two-pass skill collision classification. `global_slugs` is
170/// whatever the caller already fetched from `skill.catalog.list`'s
171/// underlying `skill_list_global()` (Store access lives in the RPC
172/// handler, not here — this stays a pure function so it's testable with a
173/// seeded fake global-skill list, per the spec's own §6 testing notes).
174/// Pass 1 (global catalog) takes priority over pass 2 (intra-bundle
175/// duplicate) when both apply, matching §3.1's stated precedence.
176pub fn classify_skill_collision(slug: &str, global_slugs: &HashSet<String>, in_bundle_dupes: &HashSet<String>) -> &'static str {
177    if global_slugs.contains(slug) {
178        "name_conflict"
179    } else if in_bundle_dupes.contains(slug) {
180        "duplicate_in_bundle"
181    } else {
182        "none"
183    }
184}
185
186/// One file read from an import source (zip or raw list), path relative
187/// to the bundle root. Mirrors `bundle_export::BundleExportFile`.
188#[derive(Debug, Clone)]
189pub struct BundleImportFile {
190    pub path: String,
191    pub content: String,
192}
193
194/// A skill parsed out of a `skills/<slug>/SKILL.md` file — the inverse of
195/// `agent_config::render_skill_md`.
196#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
197pub struct ParsedSkill {
198    /// The exact `components.skills` directory reference that produced this
199    /// entry (Phase 3 spec §3.0) — a stable, always-unique selection key,
200    /// independent of `slug` (which two entries can share; see the
201    /// `"duplicate_in_bundle"` collision case). Never displayed truncated;
202    /// only used to match a preview row to a commit selection.
203    pub source_dir: String,
204    /// The slug from SKILL.md's own `name` field (Agent Skills spec:
205    /// must match the parent directory) — reused as both the imported
206    /// skill's display name and its `trigger`. Agent-skill-type skills
207    /// don't materialize a slash-command file from `trigger` (see
208    /// `agent_config.rs`'s `buildConfigFiles`), so this is a safe,
209    /// deterministic default rather than a meaningful distinct value.
210    pub slug: String,
211    pub description: String,
212    pub content: String,
213}
214
215/// An MCP server parsed out of a `components.mcpServers` reference — Phase
216/// 3 spec §3.0. `source_path` is the stable, always-unique selection key
217/// (the manifest path reference, distinct from whatever `"name"` field
218/// happens to appear inside `config`'s arbitrary JSON, which has no
219/// uniqueness guarantee and isn't even required to be present).
220#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
221pub struct ParsedMcpServer {
222    pub source_path: String,
223    pub config: Value,
224}
225
226/// One `accounts/requirements.json` entry — mirrors the shape
227/// `bundle_export.rs` writes (research report §5.3).
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
229pub struct AccountRequirement {
230    pub id: String,
231    /// ABF v0.2 (SPEC_ABF_V0_2_PROVIDER_AWARE_COMPONENTS_AND_NATIVE_MEMORY_
232    /// 2026_08_10.md §2.1): the wire key is `credentialProvider`, renamed
233    /// from the ambiguous v0.1 `provider` (which collided with the
234    /// unrelated harness/model-vendor "provider" concept components.
235    /// instructions now uses — §2.2). `alias` keeps a v0.1-produced bundle
236    /// (still carrying the old key) importing unchanged; new exports only
237    /// ever write `credentialProvider`. The Rust field itself keeps its
238    /// name — every internal caller (account-requirement resolution,
239    /// `db_accounts.provider` matching) already reads it as "which
240    /// credential service", which was always accurate; only the wire
241    /// format needed disambiguating from the newer, unrelated sense.
242    #[serde(rename = "credentialProvider", alias = "provider")]
243    pub provider: String,
244    #[serde(default)]
245    pub kind: String,
246    #[serde(default)]
247    pub env: String,
248    #[serde(default)]
249    pub optional: bool,
250}
251
252/// One `instructions/context/*` file, in the `[{path, content}]` shape
253/// `db_bundles.context_files` stores.
254#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
255pub struct ImportedContextFile {
256    /// 0-based index within this parse's `context_files` list (Phase 3
257    /// spec §3.1, round 13) — the stable selection key `bundle.import.commit`'s
258    /// `include_context_files` uses. Deterministic and reusable across
259    /// `preview`/`commit` because `expected_content_digest` already
260    /// guarantees both calls parse identical content, so the same index
261    /// always means the same entry. Never used for display.
262    pub id: usize,
263    pub path: String,
264    pub content: String,
265}
266
267/// One `components.projectInstructions` entry, read back out of the bundle.
268///
269/// Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. This is
270/// a **record of what the SOURCE agent was reading**, not content to install:
271/// no import path writes these files, and `owner` is the field that says why
272/// (a `foreign` entry belongs to the source repository, and writing it into
273/// the importing one is exactly what
274/// `SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md` exists to prevent).
275///
276/// Carried through the parser rather than being reduced to a warning because
277/// "surfaced" has to mean readable to be worth anything: `agent.
278/// project_instructions` scans the IMPORTING agent's working directory, so it
279/// is structurally unable to show what the source machine had (Codex P1,
280/// PR #3163). Without this the import flow could tell you the component
281/// existed and nothing more.
282#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
283pub struct ImportedProjectInstruction {
284    /// Working-directory-relative path on the SOURCE machine, e.g. `CLAUDE.md`.
285    pub path: String,
286    /// Where the snapshot lives inside the bundle (`instructions/project/...`).
287    pub file: String,
288    /// SHA-256 the exporter recorded for the source bytes. Kept verbatim and
289    /// never recomputed here — a mismatch against `content` is itself
290    /// information about the bundle, not something to paper over.
291    pub content_hash: String,
292    /// `"agentmux"` or `"foreign"`, verbatim from the manifest. Not parsed
293    /// into an enum: an unrecognized value from a future/hand-written bundle
294    /// must survive to the UI as-is rather than being silently coerced to
295    /// `foreign`, which would understate AgentMux's own authorship.
296    pub owner: String,
297    /// The snapshot's content, resolved from the bundle's files.
298    pub content: String,
299}
300
301/// Parsed, validated bundle content ready for the RPC handler to resolve
302/// accounts against and write to the Store. `mcp_servers` still contains
303/// whatever `${VAR}`-style placeholders the exporter's redaction left in
304/// place — account resolution (§4.5, RPC-handler-side) substitutes real
305/// account bindings where exactly one match is found and leaves the rest
306/// untouched.
307#[derive(Debug, Clone, Serialize)]
308pub struct ParsedBundleImport {
309    pub name: String,
310    pub description: String,
311    /// ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md §7.4.3: harness +
312    /// vendor, carried through from the manifest's `suggestedFor` (v0.3) or
313    /// top-level `provider`/`model` (v0.1–v0.2). Empty when it has neither
314    /// (an older export predating this, or a bundle made with no hint). A
315    /// hint only: it never sets an agent's provider.
316    pub provider: String,
317    pub model: String,
318    pub instructions: String,
319    /// ABF v0.2 §2.2: `{provider_id: content}` — every non-"default"
320    /// variant found in a v0.2 `components.instructions` object, stored
321    /// verbatim with no merge decision made here. Empty for a v0.1 bundle
322    /// (flat array) or a v0.2 bundle with no provider-scoped variants.
323    pub instructions_by_provider: HashMap<String, String>,
324    pub context_files: Vec<ImportedContextFile>,
325    pub mcp_servers: Vec<ParsedMcpServer>,
326    pub skills: Vec<ParsedSkill>,
327    pub skipped_skills: Vec<String>,
328    pub requirements: Vec<AccountRequirement>,
329    /// What the SOURCE agent was reading as project instructions. Read-only —
330    /// see [`ImportedProjectInstruction`]. No caller writes these anywhere;
331    /// `PROJECT_INSTRUCTIONS_NOT_APPLIED_WARNING` rides alongside them.
332    pub project_instructions: Vec<ImportedProjectInstruction>,
333    pub warnings: Vec<String>,
334}
335
336/// Normalize every input file's path and reduce to a first-wins,
337/// deduped `(path -> content)` map — the exact effective representation
338/// `parse_bundle_import` builds internally before doing anything else.
339/// Extracted so the Phase 3 `files`-mode content digest (§3.0.5, round 6)
340/// can compute over the IDENTICAL order-resolved representation the parser
341/// itself uses, rather than a naive raw-input sort that could disagree
342/// with which entry the parser's own first-wins rule actually keeps.
343///
344/// Also enforces the accounts/ allowlist (§4.3.5): only
345/// `accounts/requirements.json` is ever readable from that directory,
346/// checked against every file actually present (not just what the
347/// manifest references, since a malformed bundle's `components` object
348/// isn't a trustworthy inventory of its own contents), and normalized the
349/// same way regardless of intake source (zip or raw `files` list) so
350/// neither can bypass the check with a non-canonical spelling
351/// (codex P1, PR #2379 rounds 1–2). A rejected file is excluded from the
352/// returned map entirely, not merely warned about — otherwise a
353/// `components.*` reference pointing at it (e.g.
354/// `accounts/secrets.json`) would still resolve and leak its content into
355/// the imported bundle.
356fn dedup_files_by_path<'a>(files: &'a [BundleImportFile], warnings: &mut WarningSink) -> HashMap<String, &'a str> {
357    let mut by_path: HashMap<String, &str> = HashMap::new();
358    for f in files {
359        let Some(safe_path) = sanitize_context_relative_path(&f.path) else {
360            warnings.push(format!("{}: not a safe path; skipped", f.path));
361            continue;
362        };
363        // reagent P1, PR #2379 round 4: case-insensitive on the DIRECTORY
364        // check -- sanitize_context_relative_path never case-folds, so
365        // `ACCOUNTS/secrets.json` (or any other-case variant) previously
366        // sailed past the literal lowercase `starts_with` check while a
367        // manifest reference using the identical casing would still
368        // resolve it. The exception itself stays exact-match against the
369        // canonical lowercase spelling the exporter always emits — an
370        // other-case "requirements.json" is still inside the rejected
371        // directory, just not recognized as the one allowed file.
372        if safe_path.to_ascii_lowercase().starts_with("accounts/") && safe_path != "accounts/requirements.json" {
373            warnings.push(format!(
374                "{safe_path}: rejected — only accounts/requirements.json is ever read from the accounts/ directory"
375            ));
376            continue;
377        }
378        // reagent P2, PR #2379 round 5: `unzip_bundle_import` explicitly
379        // detects and warns on a duplicate entry within a zip ("first
380        // occurrence kept"), but the raw `files` RPC list reaches this
381        // shared loop directly, bypassing that pass entirely — two input
382        // entries normalizing to the same safe_path silently resolved
383        // last-write-wins with no warning, so a caller inspecting the
384        // input couldn't tell which content actually got imported.
385        if by_path.contains_key(&safe_path) {
386            warnings.push(format!("{safe_path}: duplicate path in input; first occurrence kept"));
387            continue;
388        }
389        by_path.insert(safe_path, f.content.as_str());
390    }
391    by_path
392}
393
394/// Which of the three `bundle.import.preview`/`.commit` input fields
395/// produced a given payload — mixed into the content-digest hash domain
396/// itself (Phase 3 spec §3.0.5, round 7) so a `file_path` preview can never
397/// be satisfied by a `zip_base64` commit of the identical underlying bytes
398/// (both canonicalize to the same raw zip bytes and would otherwise hash
399/// identically), closing the gap a bare byte-digest comparison left open.
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum ImportInputMode {
402    FilePath,
403    ZipBase64,
404    Files,
405}
406
407impl ImportInputMode {
408    fn mode_byte(self) -> u8 {
409        match self {
410            ImportInputMode::FilePath => 0x01,
411            ImportInputMode::ZipBase64 => 0x02,
412            ImportInputMode::Files => 0x03,
413        }
414    }
415}
416
417/// SHA-256 content digest for `file_path`/`zip_base64` input — both
418/// canonicalize to the same thing (raw zip bytes), differentiated only by
419/// the mode tag mixed into the hash domain (§3.0.5, round 7).
420pub fn content_digest_raw_bytes(mode: ImportInputMode, zip_bytes: &[u8]) -> String {
421    use sha2::{Digest, Sha256};
422    let mut hasher = Sha256::new();
423    hasher.update([mode.mode_byte()]);
424    hasher.update(zip_bytes);
425    hex::encode(hasher.finalize())
426}
427
428/// SHA-256 content digest for `files`-mode input (§3.0.5, round 6) — hashes
429/// `parse_bundle_import`'s own effective, order-resolved representation
430/// ([`dedup_files_by_path`]'s normalize-then-first-wins reduction, sorted
431/// by normalized key), not the raw input array. This makes the digest
432/// order-independent for genuinely equivalent inputs while remaining
433/// sensitive to any reordering that would actually change which entry the
434/// parser's first-wins rule keeps.
435pub fn content_digest_files(files: &[BundleImportFile]) -> String {
436    use sha2::{Digest, Sha256};
437    let mut discard = WarningSink::new(WarningBudget::unbounded());
438    let deduped = dedup_files_by_path(files, &mut discard);
439    let mut entries: Vec<(&String, &&str)> = deduped.iter().collect();
440    entries.sort_by(|a, b| a.0.cmp(b.0));
441    let mut hasher = Sha256::new();
442    hasher.update([ImportInputMode::Files.mode_byte()]);
443    for (path, content) in entries {
444        hasher.update((path.len() as u64).to_le_bytes());
445        hasher.update(path.as_bytes());
446        hasher.update((content.len() as u64).to_le_bytes());
447        hasher.update(content.as_bytes());
448    }
449    hex::encode(hasher.finalize())
450}
451
452/// Maximum number of distinct keys accepted in a v0.2
453/// `components.instructions` object (`"default"` plus per-provider
454/// variants) — ABF v0.2 §2.2. A real bundle needs at most one entry per
455/// known harness (nine, as of `providers.rs`) plus `"default"`; generous
456/// headroom for future providers without leaving this unbounded, mirroring
457/// this module's other component-count caps (e.g.
458/// [`MAX_ACCOUNT_REQUIREMENTS`]).
459const MAX_INSTRUCTION_PROVIDER_VARIANTS: usize = 32;
460
461/// The exact warning `parse_bundle_import_with_budget` pushes when
462/// `components.memory` is present — ABF v0.2 §2.3. A shared constant
463/// (rather than a literal duplicated at both the push site and
464/// `bundle_import_for_agent_impl`'s filter site, reagent P1 PR #2527)
465/// so the two can never drift apart: if this message ever changes, the
466/// filter in `bundle.rs` keeps working because it references the same
467/// constant, not a copy of the string.
468/// Warning for an ABF carrying `components.projectInstructions`.
469///
470/// Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. These
471/// entries record what the *source* agent was reading. They are surfaced and
472/// never installed — the files they describe belong to whatever repository the
473/// bundle is being imported into, and an import that wrote them would be doing
474/// what `SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md` exists to prevent.
475///
476/// Unlike [`MEMORY_COMPONENT_IGNORED_WARNING`] this has no filter site: memory
477/// warns only on the agent-less path because `bundle.import_for_agent` really
478/// does install it, whereas no import path installs these. The warning is
479/// therefore unconditional and permanent, not a "use the other RPC" pointer.
480pub(crate) const PROJECT_INSTRUCTIONS_NOT_APPLIED_WARNING: &str =
481    "components.projectInstructions: recorded for reference, never installed — these files belong to the importing repository, not to the bundle. Inspect them with agent.project_instructions and apply anything you want deliberately";
482
483/// Cap on how many `components.projectInstructions` entries are read back.
484///
485/// Matches `project_instructions::MAX_SCANNED_INSTRUCTION_FILES`, which is the
486/// most AgentMux's own exporter can ever produce — so this can only ever bite
487/// a hand-written or hostile bundle, where refusing to allocate unboundedly is
488/// the point. Overflow warns rather than truncating silently, same rule the
489/// rest of this parser follows.
490pub const MAX_IMPORTED_PROJECT_INSTRUCTIONS: usize = 200;
491
492pub(crate) const MEMORY_COMPONENT_IGNORED_WARNING: &str =
493    "components.memory: present but ignored — memory requires an agent-scoped import (bundle.import_for_agent), not bundle.import";
494
495/// Parse one `components.instructions`-shaped path array (either the v0.1
496/// flat array itself, or one key's array within the v0.2 keyed-object
497/// shape) into its joined instructions text. `label` is used only for
498/// warning messages (`"instructions"` for the flat/default case,
499/// `"instructions.<provider>"` for a variant) so every caller's warnings
500/// are traceable to the specific key that produced them.
501///
502/// `seen_instruction_paths`/`duplicate_instruction_refs` are threaded
503/// through BY THE CALLER, shared across every invocation within one
504/// `parse_bundle_import_with_budget` call (default + every provider
505/// variant) — reagent P1, PR #2523: a HashSet local to each call only
506/// dedupes within its own component array, so the same `instructions/
507/// context/*` path referenced from both `"default"` and a provider
508/// variant was pushed into `context_files` twice, and — worse — a
509/// manifest repeating one path across many provider keys (up to
510/// [`MAX_INSTRUCTION_PROVIDER_VARIANTS`]) re-opened the exact
511/// content-cloning amplification vector the original per-array dedup
512/// existed to close (Codex P1, PR #2379 round 2, cited below).
513///
514/// `divert_context_files`: only `true` for the flat/default case.
515/// reagent P1, PR #2523: diverting any `instructions/context/*`-prefixed
516/// path applies to the raw file path unconditionally, regardless of which
517/// provider variant is being parsed — so a provider literally named
518/// `"context"` (whose exported path is `instructions/context/AGENTS.md`,
519/// per `bundle_export.rs`'s `instructions/<provider>/AGENTS.md`
520/// convention) would have its own content silently misrouted into
521/// `context_files` on a later re-import, a reserved-word collision with
522/// no validation guarding it. Context files are shared, not
523/// provider-scoped, by design (§1's naming note) — a provider variant's
524/// array never needs the diversion at all, so disabling it there closes
525/// the collision structurally rather than special-casing the "context"
526/// name.
527fn parse_instruction_component_paths(
528    arr: &[Value],
529    label: &str,
530    by_path: &HashMap<String, &str>,
531    context_files: &mut Vec<ImportedContextFile>,
532    divert_context_files: bool,
533    seen_instruction_paths: &mut HashSet<String>,
534    duplicate_instruction_refs: &mut u32,
535    warnings: &mut WarningSink,
536) -> String {
537    let mut instructions_parts: Vec<String> = Vec::new();
538    let paths = capped_component_array(Some(arr), label, warnings);
539    for path_val in paths {
540        let Some(raw_path) = path_val.as_str() else {
541            warnings.push(format!("components.{label}: non-string entry skipped"));
542            continue;
543        };
544        // codex P2, PR #2379 round 4: normalize the manifest's OWN
545        // reference the same way `by_path`'s keys are normalized
546        // (round 4's earlier fix) — otherwise a valid non-canonical
547        // spelling here (e.g. `./instructions/AGENTS.md`) no longer
548        // matches the now-canonicalized lookup key and the component
549        // is reported missing even though the file is genuinely
550        // present under an equivalent spelling.
551        let Some(path) = sanitize_context_relative_path(raw_path) else {
552            warnings.push(format!("components.{label}: \"{raw_path}\" is not a safe path; skipped"));
553            continue;
554        };
555        // codex P1, PR #2379 round 6: accounts/requirements.json is
556        // intentionally IN by_path (the dedicated parser below needs
557        // to read it), but it must never be reachable through this
558        // generic lookup — a manifest listing it here would otherwise
559        // copy its raw JSON straight into `instructions`, later
560        // written unredacted to `instructions/AGENTS.md` on export.
561        if is_requirements_json(&path) {
562            warnings.push(format!(
563                "components.{label}: \"{path}\" is the accounts/ requirements file; not readable as instructions"
564            ));
565            continue;
566        }
567        // codex P1, PR #2379 round 6: dedup already avoids cloning
568        // CONTENT per duplicate reference (round 3), but pushing one
569        // warning STRING per duplicate is itself an amplification
570        // vector — a permitted 10 MB manifest repeating one short
571        // path hundreds of thousands of times could allocate hundreds
572        // of megabytes of warning text serialized into the RPC
573        // response. Count instead; a single summary warning is pushed
574        // after the whole components.instructions parse completes
575        // (ABF v0.2, §2.2: now after every array, not just this one —
576        // see this function's own doc comment).
577        if !seen_instruction_paths.insert(path.clone()) {
578            *duplicate_instruction_refs += 1;
579            continue;
580        }
581        let Some(content) = by_path.get(path.as_str()) else {
582            warnings.push(format!("components.{label}: \"{path}\" not found among the bundle's files; skipped"));
583            continue;
584        };
585        let diverted = divert_context_files && path.strip_prefix("instructions/context/").is_some();
586        if diverted {
587            let rel = path.strip_prefix("instructions/context/").unwrap();
588            match sanitize_context_relative_path(rel) {
589                Some(safe_rel) => context_files.push(ImportedContextFile {
590                    id: context_files.len(),
591                    path: safe_rel,
592                    content: content.to_string(),
593                }),
594                None => warnings.push(format!(
595                    "{path}: not a safe relative path under instructions/context/; skipped"
596                )),
597            }
598        } else {
599            instructions_parts.push(content.to_string());
600        }
601    }
602    instructions_parts.join("\n\n---\n\n")
603}
604
605/// The manifest an ABF v0.3 archive carries
606/// (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §2.1).
607pub const MANIFEST_FILE: &str = "bundle.json";
608/// The manifest of ABF v0.1 and v0.2 archives, read forever.
609pub const LEGACY_MANIFEST_FILE: &str = "armory.json";
610/// The `$schema` an ABF v0.3 manifest names.
611pub const SCHEMA_V0_3: &str = "https://docs.agentmux.ai/schemas/agent-bundle/v0.3/bundle.schema.json";
612
613/// Which manifest file a set of archive paths carries: `bundle.json`, else
614/// the legacy `armory.json`.
615pub fn manifest_file_name(mut has: impl FnMut(&str) -> bool) -> Option<&'static str> {
616    [MANIFEST_FILE, LEGACY_MANIFEST_FILE].into_iter().find(|name| has(name))
617}
618
619/// The ABF format version a manifest declares through its `$schema`, or
620/// `None` when it names none this importer knows. The `version` field is
621/// the bundle's own content version, not this.
622pub fn abf_format_version(manifest: &Value) -> Option<&'static str> {
623    let schema = manifest.get("$schema")?.as_str()?;
624    if schema.contains("/agent-bundle/v0.3/") {
625        Some("0.3")
626    } else if schema.contains("/armory-bundle/v0.2/") {
627        Some("0.2")
628    } else if schema.contains("/armory-bundle/v0.1/") {
629        Some("0.1")
630    } else {
631        None
632    }
633}
634
635/// Parse and validate a bundle's files into [`ParsedBundleImport`].
636/// Structural failures (no manifest, `bundle.json` or `armory.json`, or a
637/// malformed one) reject the whole import — `Err` — since there is nothing safe to partially write.
638/// Per-entry problems (a missing referenced file, an unsafe path, a
639/// malformed SKILL.md) degrade to a warning and that entry is skipped —
640/// matches `bundle_export.rs`'s own philosophy, and lets a lossy import
641/// still produce a usable bundle rather than an all-or-nothing failure on
642/// e.g. one corrupt skill among five good ones.
643pub fn parse_bundle_import(files: &[BundleImportFile]) -> Result<ParsedBundleImport, String> {
644    parse_bundle_import_with_budget(files, WarningBudget::unbounded())
645}
646
647/// Same as [`parse_bundle_import`], but with an explicit [`WarningBudget`]
648/// enforced at every warning-push site inside this function (Phase 3 spec
649/// §3.1, round 11) — the new `bundle.import.preview`/`.commit` RPC handlers
650/// call this directly with a tight budget; [`parse_bundle_import`] passes
651/// [`WarningBudget::unbounded`], preserving today's existing `bundle.import`
652/// route's behavior exactly.
653pub fn parse_bundle_import_with_budget(
654    files: &[BundleImportFile],
655    budget: WarningBudget,
656) -> Result<ParsedBundleImport, String> {
657    let mut warnings = WarningSink::new(budget);
658
659    // Path normalization, first-wins dedup, and accounts/ allowlist
660    // enforcement (§4.3.5) all live in dedup_files_by_path — see its own
661    // doc comment.
662    let by_path = dedup_files_by_path(files, &mut warnings);
663
664    let manifest_name = manifest_file_name(|name| by_path.contains_key(name))
665        .ok_or_else(|| format!("{MANIFEST_FILE}: missing from bundle (or {LEGACY_MANIFEST_FILE}, before ABF v0.3)"))?;
666    if manifest_name == MANIFEST_FILE && by_path.contains_key(LEGACY_MANIFEST_FILE) {
667        warnings.push(format!("{LEGACY_MANIFEST_FILE}: ignored; this bundle also has {MANIFEST_FILE}"));
668    }
669    let manifest_raw = by_path[manifest_name];
670    let manifest: Value = serde_json::from_str(manifest_raw)
671        .map_err(|e| format!("{manifest_name}: malformed JSON ({e})"))?;
672
673    // Phase 3 spec §3.1, round 13: bound at the parse source, not at a
674    // later response boundary — `name` is re-submitted verbatim as
675    // `bundle_name` when a user doesn't edit the preview's suggested value
676    // (§3.2, round 11), so `preview` and `commit` (which independently
677    // re-parse) must converge on the identical canonical value
678    // deterministically. There is no separate "full" name anywhere for a
679    // display-only truncation to lose.
680    let raw_name = manifest
681        .get("name")
682        .and_then(|v| v.as_str())
683        .unwrap_or("imported-bundle");
684    if raw_name.chars().count() > MAX_BUNDLE_NAME_CHARS {
685        warnings.push(format!(
686            "{manifest_name}: name exceeds {MAX_BUNDLE_NAME_CHARS} characters; truncated"
687        ));
688    }
689    let name = bound_bundle_name(raw_name);
690    let description = manifest
691        .get("description")
692        .and_then(|v| v.as_str())
693        .unwrap_or("")
694        .to_string();
695    // Who the bundle was made for, a hint only: v0.3's `suggestedFor`
696    // (`vendor` is what `model` always held), or v0.1–v0.2's top-level
697    // `provider` / `model`. Absent or non-string reads as "not set."
698    let hint = |v03: &str, legacy: &str| {
699        manifest
700            .get("suggestedFor")
701            .and_then(|s| s.get(v03))
702            .or_else(|| manifest.get(legacy))
703            .and_then(|v| v.as_str())
704            .unwrap_or("")
705            .to_string()
706    };
707    let provider = hint("provider", "provider");
708    let model = hint("vendor", "model");
709
710    // §4.3.2: schema/version are recorded (via warnings, since ParsedBundleImport
711    // has no dedicated field for them yet — no schema registry exists to
712    // validate against) but never block the import.
713    if manifest.get("$schema").is_none() {
714        warnings.push(format!("{manifest_name}: no $schema field present"));
715    } else if abf_format_version(&manifest).is_none() {
716        warnings.push(format!("{manifest_name}: $schema names no ABF version this AgentMux knows; reading it anyway"));
717    }
718    // `version` is the bundle's own content version, which says nothing
719    // about the format (that's `$schema`, above), so it's never checked.
720
721    let components = manifest.get("components").and_then(|v| v.as_object());
722
723    // ------------------------------------------------------------------
724    // instructions — every path in components.instructions, concatenated
725    // in manifest order for AGENTS.md-shaped entries; instructions/context/*
726    // paths become context_files entries instead.
727    // ------------------------------------------------------------------
728    let mut context_files: Vec<ImportedContextFile> = Vec::new();
729    let mut instructions = String::new();
730    let mut instructions_by_provider: HashMap<String, String> = HashMap::new();
731    // ABF v0.2 §2.2: components.instructions is EITHER a flat array (v0.1
732    // shape, treated as an implicit "default") OR an object keyed by
733    // provider id (v0.2 shape, "default" plus zero or more provider-scoped
734    // variants). No merge decision happens here — every variant is stored
735    // verbatim; selecting one at launch time is a separate, not-yet-built
736    // materializer's job (see the spec's non-goals).
737    // Shared across every components.instructions array parsed below
738    // (default + every provider variant) — see
739    // parse_instruction_component_paths's doc comment for why this must
740    // NOT be reset per-array (reagent P1, PR #2523).
741    let mut seen_instruction_paths: HashSet<String> = HashSet::new();
742    let mut duplicate_instruction_refs: u32 = 0;
743    match components.and_then(|c| c.get("instructions")) {
744        Some(Value::Array(arr)) => {
745            instructions = parse_instruction_component_paths(
746                arr, "instructions", &by_path, &mut context_files, true,
747                &mut seen_instruction_paths, &mut duplicate_instruction_refs, &mut warnings,
748            );
749        }
750        Some(Value::Object(obj)) => {
751            if obj.len() > MAX_INSTRUCTION_PROVIDER_VARIANTS {
752                warnings.push(format!(
753                    "components.instructions: {} provider variants exceeds the limit ({MAX_INSTRUCTION_PROVIDER_VARIANTS}); only the first {MAX_INSTRUCTION_PROVIDER_VARIANTS} are used",
754                    obj.len()
755                ));
756            }
757            // "default" must be parsed FIRST, regardless of the manifest's
758            // own key order (serde_json::Map without the preserve_order
759            // feature iterates alphabetically — "claude" < "default" —
760            // not insertion order). The shared dedup set means whichever
761            // array reaches a given instructions/context/* path first
762            // decides whether it's diverted; parsing a provider variant
763            // first would let it silently claim a shared context-file
764            // path as plain instructions text before "default" ever gets
765            // a chance to divert it correctly.
766            let mut ordered: Vec<(&String, &Value)> = Vec::with_capacity(obj.len());
767            if let Some(default_entry) = obj.get_key_value("default") {
768                ordered.push(default_entry);
769            }
770            ordered.extend(obj.iter().filter(|(k, _)| *k != "default"));
771            for (key, val) in ordered.into_iter().take(MAX_INSTRUCTION_PROVIDER_VARIANTS) {
772                let label = format!("instructions.{key}");
773                let Some(arr) = val.as_array() else {
774                    warnings.push(format!("components.{label}: expected an array; skipped"));
775                    continue;
776                };
777                // Only the "default" array diverts instructions/context/*
778                // references into context_files — a provider variant's
779                // array never does (reagent P1, PR #2523; see this
780                // function's own doc comment for the reserved-word
781                // collision this closes).
782                let divert_context_files = key == "default";
783                let joined = parse_instruction_component_paths(
784                    arr, &label, &by_path, &mut context_files, divert_context_files,
785                    &mut seen_instruction_paths, &mut duplicate_instruction_refs, &mut warnings,
786                );
787                if key == "default" {
788                    instructions = joined;
789                } else {
790                    instructions_by_provider.insert(key.clone(), joined);
791                }
792            }
793        }
794        Some(_) => warnings.push("components.instructions: expected an array or object; skipped".to_string()),
795        None => {}
796    }
797    if duplicate_instruction_refs > 0 {
798        warnings.push(format!(
799            "components.instructions: {duplicate_instruction_refs} duplicate reference(s) skipped"
800        ));
801    }
802
803    // ------------------------------------------------------------------
804    // skills — every directory in components.skills, reading <dir>/SKILL.md
805    // ------------------------------------------------------------------
806    let mut skills: Vec<ParsedSkill> = Vec::new();
807    let mut skipped_skills: Vec<String> = Vec::new();
808    let mut seen_skill_dirs: HashSet<String> = HashSet::new();
809    let mut duplicate_skill_refs: u32 = 0;
810    {
811        let dirs = capped_component_array(
812            components.and_then(|c| c.get("skills")).and_then(|v| v.as_array()).map(|v| v.as_slice()),
813            "skills",
814            &mut warnings,
815        );
816        for dir_val in dirs {
817            let Some(raw_dir) = dir_val.as_str() else {
818                warnings.push("components.skills: non-string entry skipped".to_string());
819                continue;
820            };
821            // codex P2, PR #2379 round 4: same normalization as
822            // components.instructions above.
823            let Some(dir) = sanitize_context_relative_path(raw_dir) else {
824                warnings.push(format!("components.skills: \"{raw_dir}\" is not a safe path; skipped"));
825                continue;
826            };
827            // codex P1, PR #2379 round 6: bounded the same way as
828            // components.instructions above.
829            if !seen_skill_dirs.insert(dir.clone()) {
830                duplicate_skill_refs += 1;
831                continue;
832            }
833            let skill_md_path = format!("{}/SKILL.md", dir.trim_end_matches('/'));
834            let Some(content) = by_path.get(skill_md_path.as_str()) else {
835                warnings.push(format!("components.skills: \"{skill_md_path}\" not found; skipped"));
836                skipped_skills.push(dir.clone());
837                continue;
838            };
839            match parse_skill_md(content) {
840                Some(mut skill) => {
841                    skill.source_dir = dir.clone();
842                    skills.push(skill);
843                }
844                None => {
845                    warnings.push(format!("{skill_md_path}: malformed SKILL.md frontmatter; skipped"));
846                    skipped_skills.push(dir.clone());
847                }
848            }
849        }
850    }
851    if duplicate_skill_refs > 0 {
852        warnings.push(format!("components.skills: {duplicate_skill_refs} duplicate reference(s) skipped"));
853    }
854    // codex P1, PR #2379 round 7: bounds the RPC handler's WRITE side --
855    // see MAX_IMPORTED_SKILLS's doc comment. The truncated skills still
856    // count as "skipped" for reporting purposes, matching every other
857    // skip reason in this loop.
858    if skills.len() > MAX_IMPORTED_SKILLS {
859        warnings.push(format!(
860            "components.skills: {} skills exceeds the import limit ({MAX_IMPORTED_SKILLS}); only the first {MAX_IMPORTED_SKILLS} are imported",
861            skills.len()
862        ));
863        skipped_skills.extend(skills.split_off(MAX_IMPORTED_SKILLS).into_iter().map(|s| s.slug));
864    }
865
866    // ------------------------------------------------------------------
867    // mcp servers — every path in components.mcpServers, parsed as JSON
868    // verbatim (still containing ${VAR} placeholders; resolution is the
869    // RPC handler's job, §4.5).
870    // ------------------------------------------------------------------
871    let mut mcp_servers: Vec<ParsedMcpServer> = Vec::new();
872    let mut seen_mcp_paths: HashSet<String> = HashSet::new();
873    let mut duplicate_mcp_refs: u32 = 0;
874    {
875        let paths = capped_component_array(
876            components.and_then(|c| c.get("mcpServers")).and_then(|v| v.as_array()).map(|v| v.as_slice()),
877            "mcpServers",
878            &mut warnings,
879        );
880        for path_val in paths {
881            let Some(raw_path) = path_val.as_str() else {
882                warnings.push("components.mcpServers: non-string entry skipped".to_string());
883                continue;
884            };
885            // codex P2, PR #2379 round 4: same normalization as
886            // components.instructions above.
887            let Some(path) = sanitize_context_relative_path(raw_path) else {
888                warnings.push(format!("components.mcpServers: \"{raw_path}\" is not a safe path; skipped"));
889                continue;
890            };
891            // codex P1, PR #2379 round 6: same rejection as
892            // components.instructions above -- requirements.json's raw
893            // content is valid JSON and would otherwise parse cleanly
894            // into an mcpServers entry, leaking it the same way.
895            if is_requirements_json(&path) {
896                warnings.push(format!(
897                    "components.mcpServers: \"{path}\" is the accounts/ requirements file; not readable as an MCP server config"
898                ));
899                continue;
900            }
901            // codex P1, PR #2379 round 6: bounded the same way as
902            // components.instructions above.
903            if !seen_mcp_paths.insert(path.clone()) {
904                duplicate_mcp_refs += 1;
905                continue;
906            }
907            let Some(content) = by_path.get(path.as_str()) else {
908                warnings.push(format!("components.mcpServers: \"{path}\" not found; skipped"));
909                continue;
910            };
911            match serde_json::from_str::<Value>(content) {
912                Ok(config) => mcp_servers.push(ParsedMcpServer { source_path: path.clone(), config }),
913                Err(e) => warnings.push(format!("{path}: malformed JSON ({e}); skipped")),
914            }
915        }
916    }
917    if duplicate_mcp_refs > 0 {
918        warnings.push(format!("components.mcpServers: {duplicate_mcp_refs} duplicate reference(s) skipped"));
919    }
920
921    // ------------------------------------------------------------------
922    // accounts/requirements.json — read-only input to §4.5, never written
923    // anywhere as-is.
924    // ------------------------------------------------------------------
925    let mut requirements: Vec<AccountRequirement> = Vec::new();
926    if let Some(accounts_val) = components.and_then(|c| c.get("accounts")) {
927        // reagent P2, PR #2379 round 7: every other component category
928        // (instructions/skills/mcpServers non-string entries, an
929        // unrecognized `version`) pushes an explicit warning when the
930        // manifest's value has the wrong shape — this one silently
931        // dropped a non-string `accounts` value with no warning at all,
932        // inconsistent with the module's own "warn, don't silently drop"
933        // philosophy.
934        if let Some(raw_req_path) = accounts_val.as_str() {
935            // reagent P2, PR #2379 round 6: this was the one remaining
936            // manifest-reference lookup still comparing/using the raw,
937            // un-normalized string -- the same bug class round 4 fixed for
938            // components.instructions/skills/mcpServers, just missed here. A
939            // valid non-canonical spelling (e.g. "./accounts/requirements.json")
940            // failed the literal equality check, silently dropping legitimate
941            // account requirements.
942            let req_path = sanitize_context_relative_path(raw_req_path).filter(|p| is_requirements_json(p));
943            if let Some(req_path) = &req_path {
944                if let Some(content) = by_path.get(req_path.as_str()) {
945                    #[derive(Deserialize)]
946                    struct RequirementsDoc {
947                        #[serde(default)]
948                        requirements: Vec<AccountRequirement>,
949                    }
950                    match serde_json::from_str::<RequirementsDoc>(content) {
951                        Ok(doc) => {
952                            // codex P1, PR #2379 round 4: unbounded, the RPC
953                            // handler's per-requirement account lookup becomes a
954                            // synchronous store query per row — a permitted 10 MB
955                            // JSON entry can hold tens/hundreds of thousands of
956                            // (duplicate or distinct) requirements and keep that
957                            // handler busy for a prolonged time. Bounding here
958                            // protects the parse step itself; the handler
959                            // separately dedupes by provider so its actual query
960                            // count stays low even at this cap.
961                            if doc.requirements.len() > MAX_ACCOUNT_REQUIREMENTS {
962                                warnings.push(format!(
963                                    "accounts/requirements.json: {} requirements exceeds the limit ({MAX_ACCOUNT_REQUIREMENTS}); only the first {MAX_ACCOUNT_REQUIREMENTS} are used",
964                                    doc.requirements.len()
965                                ));
966                                requirements = doc.requirements.into_iter().take(MAX_ACCOUNT_REQUIREMENTS).collect();
967                            } else {
968                                requirements = doc.requirements;
969                            }
970                        }
971                        Err(e) => warnings.push(format!("accounts/requirements.json: malformed JSON ({e}); ignored")),
972                    }
973                } else {
974                    warnings.push("components.accounts references accounts/requirements.json, but it's not present in the bundle".to_string());
975                }
976            } else {
977                warnings.push(format!(
978                    "components.accounts: \"{raw_req_path}\" is not accounts/requirements.json; ignored per the accounts/ allowlist"
979                ));
980            }
981        } else {
982            warnings.push("components.accounts: non-string value skipped".to_string());
983        }
984    }
985
986    // ABF v0.2 §2.3: `parse_bundle_import`/`parse_bundle_import_with_budget`
987    // are called by every agent-LESS import path (bundle.import,
988    // bundle.import.preview, bundle.import.commit) AND by
989    // `bundle_import_for_agent_impl` (which reuses this same parser for
990    // everything except memory, which it handles separately downstream).
991    // A components.memory key gets a warning here so the three agent-less
992    // callers can tell the memory component was present but requires
993    // bundle.import_for_agent, not that it was missing from the source
994    // bundle. reagent P1, PR #2527: `bundle_import_for_agent_impl` DOES
995    // handle memory, so this warning is actively misleading there —
996    // it must filter this exact string out of `parsed.warnings` before
997    // merging into its own response (see MEMORY_COMPONENT_IGNORED_WARNING's
998    // doc comment for why it's a shared constant, not a duplicated
999    // literal, so the push site and the filter site can't drift apart).
1000    if components.and_then(|c| c.get("memory")).is_some() {
1001        warnings.push(MEMORY_COMPONENT_IGNORED_WARNING.to_string());
1002    }
1003
1004    // Unlike memory, this warning has no filter site anywhere: NO import path
1005    // installs project instructions, agent-scoped or not. Those files belong
1006    // to the repository the bundle is being imported INTO, and writing them
1007    // would be exactly what SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md
1008    // exists to prevent — a portability feature turning into a back door
1009    // (SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md §5.2).
1010    //
1011    // The entries themselves ARE read back (Codex P1, PR #3163): a warning
1012    // saying "this bundle recorded some instructions" with no way to see them
1013    // is not surfacing anything, and `agent.project_instructions` can't fill
1014    // the gap because it scans the IMPORTING agent's working directory.
1015    // Reading is not applying — nothing downstream writes these.
1016    let mut project_instructions: Vec<ImportedProjectInstruction> = Vec::new();
1017    if let Some(raw) = components.and_then(|c| c.get("projectInstructions")) {
1018        warnings.push(PROJECT_INSTRUCTIONS_NOT_APPLIED_WARNING.to_string());
1019        match raw.as_array() {
1020            Some(entries) => {
1021                if entries.len() > MAX_IMPORTED_PROJECT_INSTRUCTIONS {
1022                    warnings.push(format!(
1023                        "components.projectInstructions: {} entries exceeds the \
1024                         {MAX_IMPORTED_PROJECT_INSTRUCTIONS}-entry cap; only the \
1025                         first {MAX_IMPORTED_PROJECT_INSTRUCTIONS} were read",
1026                        entries.len()
1027                    ));
1028                }
1029                for entry in entries.iter().take(MAX_IMPORTED_PROJECT_INSTRUCTIONS) {
1030                    let Some(obj) = entry.as_object() else {
1031                        warnings.push(
1032                            "components.projectInstructions: non-object entry skipped"
1033                                .to_string(),
1034                        );
1035                        continue;
1036                    };
1037                    let str_field = |k: &str| {
1038                        obj.get(k).and_then(|v| v.as_str()).unwrap_or_default().to_string()
1039                    };
1040                    let raw_file = str_field("file");
1041                    if raw_file.is_empty() {
1042                        warnings.push(
1043                            "components.projectInstructions: entry has no \"file\"; skipped"
1044                                .to_string(),
1045                        );
1046                        continue;
1047                    }
1048                    // Normalize the manifest's OWN reference the same way
1049                    // `by_path`'s keys are normalized, exactly as every other
1050                    // manifest-reference lookup in this file does (codex P2,
1051                    // PR #2379 round 4; reagent P2, round 6; reagent P2 again
1052                    // on PR #3163 — this is the third component to need it).
1053                    // Without it a valid non-canonical spelling
1054                    // (`./instructions/project/CLAUDE.md`, backslashes) misses
1055                    // the lookup and gets reported as "not found" even though
1056                    // the content is right there.
1057                    let Some(file_path) = sanitize_context_relative_path(&raw_file) else {
1058                        warnings.push(format!(
1059                            "components.projectInstructions: \"{raw_file}\" is not a \
1060                             safe path; skipped"
1061                        ));
1062                        continue;
1063                    };
1064                    // The accounts/ allowlist applies here for the same reason
1065                    // it applies to components.instructions: a manifest is not
1066                    // a trustworthy inventory of its own bundle, and a
1067                    // projectInstructions entry must not become a second way to
1068                    // read accounts/requirements.json back out as content.
1069                    // Checked on the NORMALIZED path so a non-canonical
1070                    // spelling can't slip past it either.
1071                    if is_requirements_json(&file_path) {
1072                        warnings.push(format!(
1073                            "components.projectInstructions: \"{raw_file}\" is the \
1074                             accounts/ requirements file; not readable as instructions"
1075                        ));
1076                        continue;
1077                    }
1078                    let Some(content) = by_path.get(file_path.as_str()) else {
1079                        warnings.push(format!(
1080                            "components.projectInstructions: \"{raw_file}\" not found \
1081                             among the bundle's files; skipped"
1082                        ));
1083                        continue;
1084                    };
1085                    project_instructions.push(ImportedProjectInstruction {
1086                        path: str_field("path"),
1087                        file: file_path,
1088                        content_hash: str_field("contentHash"),
1089                        owner: str_field("owner"),
1090                        content: content.to_string(),
1091                    });
1092                }
1093            }
1094            None => warnings.push(
1095                "components.projectInstructions: not an array; ignored".to_string(),
1096            ),
1097        }
1098    }
1099
1100    Ok(ParsedBundleImport {
1101        name,
1102        description,
1103        provider,
1104        model,
1105        instructions,
1106        instructions_by_provider,
1107        context_files,
1108        mcp_servers,
1109        skills,
1110        skipped_skills,
1111        requirements,
1112        project_instructions,
1113        warnings: warnings.into_vec(),
1114    })
1115}
1116
1117/// True if `path` is (case-insensitively) the one file ever read out of
1118/// the accounts/ directory. Used both to allow it into `by_path` (the
1119/// accounts/ allowlist above) and to REJECT it from every OTHER
1120/// component category's generic lookup (codex P1, PR #2379 round 6) —
1121/// components.instructions/mcpServers must never be able to pull its raw
1122/// JSON content in as if it were ordinary bundle content (e.g. straight
1123/// into `instructions/AGENTS.md` on a later export, or into an mcpServer
1124/// entry), since only the dedicated accounts/requirements.json parser is
1125/// meant to ever read it.
1126fn is_requirements_json(path: &str) -> bool {
1127    path.eq_ignore_ascii_case("accounts/requirements.json")
1128}
1129
1130/// Bounds a manifest component array's length BEFORE any per-entry
1131/// processing happens, so one cap protects every warning a per-entry
1132/// loop can produce (non-string entries, unsafe paths, not-found
1133/// lookups, malformed content, ...) at once — codex P1, PR #2379 round
1134/// 7: round 6 bounded only the "duplicate reference" warning
1135/// specifically; a manifest filled with non-string junk values hit an
1136/// entirely different (still unbounded) warning path for the same
1137/// amplification effect. Reuses [`MAX_ENTRY_COUNT`] — a manifest
1138/// component array legitimately needs the same "more entries than any
1139/// real bundle uses" ceiling a zip archive's entry count does.
1140fn capped_component_array<'a>(arr: Option<&'a [Value]>, key: &str, warnings: &mut WarningSink) -> &'a [Value] {
1141    let Some(arr) = arr else { return &[] };
1142    let len = arr.len();
1143    if len > MAX_ENTRY_COUNT {
1144        warnings.push(format!(
1145            "components.{key}: {len} entries exceeds the limit ({MAX_ENTRY_COUNT}); only the first {MAX_ENTRY_COUNT} are used"
1146        ));
1147        &arr[..MAX_ENTRY_COUNT]
1148    } else {
1149        arr
1150    }
1151}
1152
1153/// Parse a SKILL.md file (`render_skill_md`'s exact output shape, and
1154/// nothing more general — this is not a YAML parser, just the inverse of
1155/// the one writer this codebase has). Expects exactly:
1156/// `---\nname: "<json-string>"\ndescription: "<json-string>"\n---\n\n<body>`.
1157/// Returns `None` for anything that doesn't match — a real Agent Skills
1158/// SKILL.md from elsewhere in the ecosystem may use additional frontmatter
1159/// fields or different quoting; broadening this to a general YAML parser
1160/// is a follow-up, not silently guessed at here (mirrors the exporter's
1161/// own documented `server.json` scope decision).
1162fn parse_skill_md(content: &str) -> Option<ParsedSkill> {
1163    let rest = content.strip_prefix("---\n")?;
1164    let (frontmatter, body) = rest.split_once("\n---\n\n")?;
1165    let mut name: Option<String> = None;
1166    let mut description: Option<String> = None;
1167    for line in frontmatter.lines() {
1168        let (key, value) = line.split_once(": ")?;
1169        let parsed: String = serde_json::from_str(value).ok()?;
1170        match key {
1171            "name" => name = Some(parsed),
1172            "description" => description = Some(parsed),
1173            _ => {} // unrecognized frontmatter field — ignore, don't fail
1174        }
1175    }
1176    Some(ParsedSkill {
1177        // Filled in by the caller (parse_bundle_import), which alone knows
1178        // the components.skills directory reference that led here.
1179        source_dir: String::new(),
1180        slug: name?,
1181        // Required, not defaulted: render_skill_md always writes both
1182        // fields (falling back to a placeholder string, never omitting
1183        // the key), so a real exported SKILL.md always has both present.
1184        // A file missing `description` entirely isn't this writer's
1185        // output shape — reject it the same as a missing `name`.
1186        description: description?,
1187        content: body.to_string(),
1188    })
1189}
1190
1191/// Per-entry decompressed-size cap. Generous for any legitimate
1192/// instructions/SKILL.md/context file (10 MB of text is already an
1193/// absurdly large single bundle component) while bounding how much a
1194/// single malicious entry can force into memory.
1195const MAX_ENTRY_UNCOMPRESSED_BYTES: u64 = 10 * 1024 * 1024;
1196
1197/// Aggregate decompressed-size cap across the whole archive. Bounds a
1198/// zip-bomb-style archive built from many medium-sized entries that would
1199/// each individually pass [`MAX_ENTRY_UNCOMPRESSED_BYTES`].
1200const MAX_TOTAL_UNCOMPRESSED_BYTES: u64 = 50 * 1024 * 1024;
1201
1202/// Maximum number of entries an archive may contain. A legitimate bundle
1203/// (instructions, a handful of context files, a handful of skills, one
1204/// manifest) never comes close to this; it exists purely to bound the CPU
1205/// cost of iterating + path-sanitizing + hashing every entry, independent
1206/// of the per-entry/aggregate byte caps below (reagent P2, PR #2379 round
1207/// 2) — a crafted archive of many tiny/valid entries passes both size caps
1208/// while still forcing unbounded iteration work.
1209const MAX_ENTRY_COUNT: usize = 10_000;
1210
1211/// Maximum number of account requirements accepted from a single bundle's
1212/// `accounts/requirements.json`. A real bundle needs a handful at most —
1213/// one per distinct external account it depends on. Exists to bound the
1214/// RPC handler's downstream per-provider account-lookup work (codex P1,
1215/// PR #2379 round 4): an untrusted archive's requirements array,
1216/// unbounded, could otherwise drive a synchronous store query for every
1217/// row.
1218const MAX_ACCOUNT_REQUIREMENTS: usize = 1_000;
1219
1220/// Maximum number of skills actually imported (i.e. written to the Store)
1221/// from a single bundle. `MAX_ENTRY_COUNT`/`capped_component_array` bound
1222/// how many `components.skills` entries are even LOOKED AT, but codex P1
1223/// (PR #2379 round 7) points out that's still far too high a ceiling for
1224/// the RPC handler's write side: importing up to that many skills means
1225/// up to that many separate synchronous Store transactions, each creating
1226/// a permanent, globally-visible skill row — a compact malicious archive
1227/// (well within the size caps) could otherwise monopolize the handler and
1228/// pollute the installation's skill catalog. A real bundle needs a
1229/// handful to a few dozen skills at most.
1230///
1231/// Also reused by `bundle.import.commit` (Phase 3 spec §3.2, round 3) as
1232/// the cap on `include_skills`'s length — the same reasoning applies to a
1233/// client-supplied selection array as to the parser's own component list:
1234/// neither may drive more Store-write attempts than a real bundle could
1235/// ever need.
1236pub const MAX_IMPORTED_SKILLS: usize = 200;
1237
1238/// Maximum character length of a bundle's manifest `name`, enforced at
1239/// parse time (Phase 3 spec §3.1, round 13) rather than at a later display
1240/// boundary — `name` is re-submitted verbatim as `bundle_name` when a user
1241/// doesn't edit the preview's suggested value, so the canonical, bounded
1242/// value must be what both `preview` and `commit` converge on from the
1243/// moment parsing completes. Generous for any real bundle name.
1244pub const MAX_BUNDLE_NAME_CHARS: usize = 200;
1245
1246/// Bounds a bundle name to [`MAX_BUNDLE_NAME_CHARS`] via plain truncation
1247/// (no ellipsis, unlike [`truncate_display`]) — this IS the canonical
1248/// value, not a display abbreviation of a longer "real" one. Shared by
1249/// `parse_bundle_import`'s own manifest-`name` bounding and the Phase 3
1250/// `bundle.import.commit` RPC's `bundle_name` override (round 3), so a
1251/// client-supplied override can't bypass the same bound `parsed.name` is
1252/// already held to.
1253pub fn bound_bundle_name(name: &str) -> String {
1254    if name.chars().count() > MAX_BUNDLE_NAME_CHARS {
1255        name.chars().take(MAX_BUNDLE_NAME_CHARS).collect()
1256    } else {
1257        name.to_string()
1258    }
1259}
1260
1261/// Single choke point for the per-entry/aggregate size caps, shared by
1262/// BOTH intake paths (zip decompression and the raw `files` RPC list) —
1263/// reagent P1 / codex P1, PR #2379 round 5. Two separate gaps motivated
1264/// unifying this instead of patching each path independently:
1265///
1266/// - The raw `files` RPC branch previously ran NO size/count checks at
1267///   all, even though the spec explicitly treats it as an equally
1268///   untrusted alternate ingestion path to the zip — a hostile bundle
1269///   submitted via `files` instead of `zip_base64` bypassed every
1270///   zip-bomb/DoS defense this module had built for the zip path alone.
1271/// - Even on the zip path, an entry whose ACTUAL decompressed size
1272///   exceeds the per-entry cap was decompressed (paying the full
1273///   MAX_ENTRY_UNCOMPRESSED_BYTES-sized read) and then skipped WITHOUT
1274///   ever counting that work toward the aggregate budget — so up to
1275///   MAX_ENTRY_COUNT such entries could force on the order of
1276///   MAX_ENTRY_COUNT * MAX_ENTRY_UNCOMPRESSED_BYTES of real decompression
1277///   work while the aggregate cap never tripped.
1278///
1279/// This function accounts `content_len` toward the aggregate budget
1280/// UNCONDITIONALLY, before deciding whether the entry itself is kept —
1281/// closing the second gap structurally (every byte actually processed
1282/// counts, whether or not the entry is ultimately retained) — and both
1283/// intake paths call it, closing the first gap by construction (there is
1284/// no path left that skips this check).
1285///
1286/// Returns `Ok(true)` to keep the entry, `Ok(false)` to skip it (a
1287/// warning has already been pushed), or `Err` to reject the whole import
1288/// (aggregate cap exceeded).
1289fn check_entry_size(
1290    safe_name: &str,
1291    content_len: u64,
1292    total_uncompressed: &mut u64,
1293    warnings: &mut WarningSink,
1294) -> Result<bool, String> {
1295    *total_uncompressed = total_uncompressed.saturating_add(content_len);
1296    if *total_uncompressed > MAX_TOTAL_UNCOMPRESSED_BYTES {
1297        return Err(format!(
1298            "aggregate decompressed size exceeds the total limit ({MAX_TOTAL_UNCOMPRESSED_BYTES} bytes) — rejecting the whole import"
1299        ));
1300    }
1301    if content_len > MAX_ENTRY_UNCOMPRESSED_BYTES {
1302        warnings.push(format!(
1303            "{safe_name}: size ({content_len} bytes) exceeds the per-entry limit ({MAX_ENTRY_UNCOMPRESSED_BYTES} bytes); skipped"
1304        ));
1305        return Ok(false);
1306    }
1307    Ok(true)
1308}
1309
1310/// Applies [`check_entry_size`]'s caps (plus [`MAX_ENTRY_COUNT`]) to the
1311/// raw `files` RPC intake path — the path `unzip_bundle_import` never
1312/// covers (reagent P1, PR #2379 round 5). Unlike zip entries, a raw
1313/// file's `content` is already fully materialized (no separate
1314/// declared-vs-actual distinction, no incremental decompression to
1315/// bound), so this only needs the shared per-entry/aggregate check, no
1316/// backstop-read machinery.
1317pub fn enforce_raw_files_caps(files: Vec<BundleImportFile>) -> Result<(Vec<BundleImportFile>, Vec<String>), String> {
1318    enforce_raw_files_caps_with_budget(files, WarningBudget::unbounded())
1319}
1320
1321/// Same as [`enforce_raw_files_caps`], but with an explicit [`WarningBudget`]
1322/// (Phase 3 spec §3.1, round 11).
1323pub fn enforce_raw_files_caps_with_budget(
1324    files: Vec<BundleImportFile>,
1325    budget: WarningBudget,
1326) -> Result<(Vec<BundleImportFile>, Vec<String>), String> {
1327    if files.len() > MAX_ENTRY_COUNT {
1328        return Err(format!(
1329            "{} entries exceeds the limit ({MAX_ENTRY_COUNT}) — rejecting the whole import",
1330            files.len()
1331        ));
1332    }
1333    let mut warnings = WarningSink::new(budget);
1334    let mut total_uncompressed: u64 = 0;
1335    let mut out = Vec::new();
1336    for f in files {
1337        let keep = check_entry_size(&f.path, f.content.len() as u64, &mut total_uncompressed, &mut warnings)?;
1338        if keep {
1339            out.push(f);
1340        }
1341    }
1342    Ok((out, warnings.into_vec()))
1343}
1344
1345/// Unpack a `.abf` zip archive into a flat file list, applying the same
1346/// path-safety check as everywhere else in this module to every entry
1347/// name before it's trusted (§4.3.4) — a zip's own internal paths are
1348/// exactly as untrusted as any other part of the archive's content.
1349/// Directory entries are skipped; anything that fails the safety check is
1350/// dropped with a warning rather than surfaced as a file.
1351///
1352/// Codex P1, PR #2379: also bounds decompressed size, per-entry and in
1353/// aggregate — `read_to_string` alone has no size limit, so an untrusted
1354/// `.abf` containing a highly compressed entry (a classic zip-bomb shape;
1355/// DEFLATE alone permits ratios past 1000:1) could otherwise exhaust the
1356/// server process's memory through `bundle.import`. An oversized single
1357/// entry is skipped with a warning (matches this function's existing
1358/// per-entry-problem philosophy); an oversized AGGREGATE fails the whole
1359/// import — letting a partially-capped import through silently would be
1360/// more confusing than useful, and hitting the aggregate cap at all
1361/// already indicates a genuinely abusive archive rather than one bad file.
1362pub fn unzip_bundle_import(zip_bytes: &[u8]) -> Result<(Vec<BundleImportFile>, Vec<String>), String> {
1363    unzip_bundle_import_with_budget(zip_bytes, WarningBudget::unbounded())
1364}
1365
1366/// Same as [`unzip_bundle_import`], but with an explicit [`WarningBudget`]
1367/// (Phase 3 spec §3.1, round 11).
1368pub fn unzip_bundle_import_with_budget(
1369    zip_bytes: &[u8],
1370    budget: WarningBudget,
1371) -> Result<(Vec<BundleImportFile>, Vec<String>), String> {
1372    use std::io::Read;
1373    let cursor = std::io::Cursor::new(zip_bytes);
1374    let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("not a valid zip archive: {e}"))?;
1375
1376    if archive.len() > MAX_ENTRY_COUNT {
1377        return Err(format!(
1378            "zip archive: {} entries exceeds the limit ({MAX_ENTRY_COUNT}) — rejecting the whole import",
1379            archive.len()
1380        ));
1381    }
1382
1383    // First pass: collect every non-dir entry's raw name, to DETECT
1384    // whether every single one shares one common wrapping directory
1385    // (`zip_bundle_export`'s convention: `<root_slug>/bundle.json`, etc.)
1386    // before deciding whether to strip a path component at all.
1387    //
1388    // reagent P1, PR #2379: the previous version unconditionally stripped
1389    // the first `/`-delimited segment of EVERY entry, on the assumption a
1390    // wrapper always exists. A hand-built `.abf` zipped directly from the
1391    // loose directory tree (no wrapping folder) — which this spec's §2
1392    // explicitly requires an importer to also accept — has entries like
1393    // `instructions/AGENTS.md` already bundle-relative; blindly stripping
1394    // silently mangled it to `AGENTS.md`, breaking every `components.*`
1395    // path lookup in `parse_bundle_import` (each degrades to a "not
1396    // found" warning and the content is dropped) with no error surfaced.
1397    // Only strip when EVERY entry agrees on the same first segment.
1398    let mut raw_names: Vec<String> = Vec::new();
1399    for i in 0..archive.len() {
1400        let entry = archive
1401            .by_index(i)
1402            .map_err(|e| format!("zip archive: failed to read entry {i}: {e}"))?;
1403        if entry.is_dir() {
1404            continue;
1405        }
1406        raw_names.push(entry.name().to_string());
1407    }
1408    let common_wrapper: Option<&str> = raw_names.first().and_then(|first| {
1409        let candidate = first.split_once('/').map(|(root, _)| root)?;
1410        raw_names
1411            .iter()
1412            .all(|n| n.split_once('/').map(|(root, _)| root == candidate).unwrap_or(false))
1413            .then_some(candidate)
1414    });
1415    let strip_len: Option<usize> = common_wrapper.map(|w| w.len() + 1); // +1 for the '/'
1416
1417    let mut out = Vec::new();
1418    let mut warnings = WarningSink::new(budget);
1419    let mut seen: HashSet<String> = HashSet::new();
1420    let mut total_uncompressed: u64 = 0;
1421    for i in 0..archive.len() {
1422        let mut entry = archive
1423            .by_index(i)
1424            .map_err(|e| format!("zip archive: failed to read entry {i}: {e}"))?;
1425        if entry.is_dir() {
1426            continue;
1427        }
1428        let raw_name = entry.name().to_string();
1429        let relative_name = match strip_len {
1430            Some(n) if raw_name.len() > n => &raw_name[n..],
1431            _ => raw_name.as_str(),
1432        };
1433        let Some(safe_name) = sanitize_context_relative_path(relative_name) else {
1434            warnings.push(format!("{raw_name}: not a safe path; skipped"));
1435            continue;
1436        };
1437        if !seen.insert(safe_name.clone()) {
1438            warnings.push(format!("{safe_name}: duplicate entry in archive; first occurrence kept"));
1439            continue;
1440        }
1441
1442        // Declared size check first — cheap, and avoids decompressing at
1443        // all for an entry that's already too large per its own metadata.
1444        // This is ONLY a cheap pre-filter: `entry.size()` is the zip's own
1445        // declared/attacker-controlled uncompressed size, not a verified
1446        // value, so it must never feed the aggregate cap below (reagent
1447        // P1, PR #2379 round 2) — an archive of many entries that each lie
1448        // with a tiny declared size while actually containing content up
1449        // to the per-entry cap would otherwise pass both checks here while
1450        // still exhausting memory once decompressed.
1451        let declared_size = entry.size();
1452        if declared_size > MAX_ENTRY_UNCOMPRESSED_BYTES {
1453            warnings.push(format!(
1454                "{safe_name}: declared uncompressed size ({declared_size} bytes) exceeds the per-entry limit ({MAX_ENTRY_UNCOMPRESSED_BYTES} bytes); skipped"
1455            ));
1456            continue;
1457        }
1458
1459        // Hard backstop on the actual read, independent of the declared
1460        // size above: read at most one byte past the cap so an entry
1461        // whose real decompressed content exceeds what it declared is
1462        // still caught (rather than trusting zip metadata alone).
1463        let mut limited = entry.by_ref().take(MAX_ENTRY_UNCOMPRESSED_BYTES + 1);
1464        let mut buf: Vec<u8> = Vec::new();
1465        let read_err = limited.read_to_end(&mut buf).err();
1466
1467        // codex P1, PR #2379 round 5/6: `check_entry_size` accounts these
1468        // bytes toward the aggregate budget BEFORE deciding whether to
1469        // keep the entry, unlike the previous inline checks here (which
1470        // accumulated only KEPT entries' bytes). Called unconditionally
1471        // here — even when the read above ultimately errored (e.g. a
1472        // forged CRC on an otherwise-decompressing entry) — since
1473        // `read_to_end` can leave real decompressed bytes in `buf` before
1474        // returning an error; that decompression work already happened
1475        // regardless of the read's outcome, and up to MAX_ENTRY_COUNT
1476        // such corrupt entries must not be able to force ~10MB of work
1477        // each while the aggregate counter stays untouched.
1478        let keep = check_entry_size(&safe_name, buf.len() as u64, &mut total_uncompressed, &mut warnings)?;
1479
1480        if let Some(e) = read_err {
1481            warnings.push(format!("{safe_name}: failed to read entry: {e}"));
1482            continue;
1483        }
1484        if !keep {
1485            continue;
1486        }
1487        let content = match String::from_utf8(buf) {
1488            Ok(s) => s,
1489            Err(e) => {
1490                warnings.push(format!("{safe_name}: not valid UTF-8 text; skipped ({e})"));
1491                continue;
1492            }
1493        };
1494        out.push(BundleImportFile { path: safe_name, content });
1495    }
1496    Ok((out, warnings.into_vec()))
1497}
1498
1499#[cfg(test)]
1500#[path = "bundle_import_tests.rs"]
1501mod tests;