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;