Skip to main content

agentmux_srv/server/app_api/bundle/
import.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `bundle.import`, `bundle.import.preview`, `bundle.import.commit`: the ABF
5//! file/bytes intake path, its size and warning budgets, and the two-phase
6//! preview/commit flow.
7
8use super::*;
9
10#[derive(serde::Deserialize, Default, ts_rs::TS)]
11#[ts(export, export_to = "../../../frontend/types/rpc/")]
12pub(super) struct BundleImportReq {
13    #[serde(default)]
14    #[ts(optional)]
15    zip_base64: Option<String>,
16    #[serde(default)]
17    #[ts(optional)]
18    files: Option<Vec<FileEntry>>,
19}
20
21/// Response for `bundle.import`.
22#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
23#[ts(export, export_to = "../../../frontend/types/rpc/")]
24pub(crate) struct BundleImportResponse {
25    pub(crate) bundle_id: String,
26    pub(crate) imported_skill_ids: Vec<String>,
27    pub(crate) skipped_skills: Vec<String>,
28    pub(crate) resolved_requirement_ids: Vec<String>,
29    pub(crate) unresolved_requirements: Vec<crate::backend::rpc_types::BundleImportUnresolvedRequirement>,
30    pub(crate) warnings: Vec<String>,
31}
32
33/// `bundle.import` — ABF importer, Phase 2 of
34/// docs/specs/SPEC_ABF_V0_1_SINGLE_FILE_AND_IMPORTER_2026_08_01.md. Window-
35/// scoped like `bundle.export` (bundles aren't agent-specific). Accepts
36/// EITHER `zip_base64` (a `.abf` archive, mirroring `bundle.export`'s own
37/// `zip_base64` response field) OR `files` (a raw `[{path, content}]`
38/// list) — exactly one must be present. Hands the bytes to the pure
39/// `bundle_import` module for parsing/validation, then owns every Store
40/// side-effect: creates the bundle row, creates one global Skill row per
41/// parsed skill, and performs READ-ONLY account-requirement lookups
42/// (never creates an account, never writes a secret anywhere — see the
43/// spec's §4.5 correction for why this stopped short of "resolving"
44/// placeholders in place).
45pub(super) fn register_bundle_import(engine: &Arc<WshRpcEngine>, state: &AppState) {
46    let id_store = state.id_store.clone();
47    let identity_store = state.identity_store.clone();
48    let mstore = state.mstore.clone();
49    let broker = state.broker.clone();
50    engine.register_typed(
51        COMMAND_BUNDLE_IMPORT,
52        move |req: BundleImportReq, _ctx| {
53            let id_store = id_store.clone();
54            let identity_store = identity_store.clone();
55            let mstore = mstore.clone();
56            let broker = broker.clone();
57            async move {
58                let (files, mut warnings): (Vec<crate::backend::bundle_import::BundleImportFile>, Vec<String>) =
59                    match (req.zip_base64, req.files) {
60                        (Some(_), Some(_)) => {
61                            return Err(
62                                "bundle.import: exactly one of zip_base64/files must be present, got both"
63                                    .to_string(),
64                            );
65                        }
66                        (None, None) => {
67                            return Err(
68                                "bundle.import: exactly one of zip_base64/files must be present, got neither"
69                                    .to_string(),
70                            );
71                        }
72                        (Some(b64), None) => {
73                            use base64::Engine as _;
74                            let zip_bytes = base64::engine::general_purpose::STANDARD
75                                .decode(&b64)
76                                .map_err(|e| format!("bundle.import: invalid zip_base64: {e}"))?;
77                            crate::backend::bundle_import::unzip_bundle_import(&zip_bytes)
78                                .map_err(|e| format!("bundle.import: {e}"))?
79                        }
80                        (None, Some(files)) => {
81                            // reagent P1, PR #2379 round 5: the raw `files`
82                            // list previously skipped straight to
83                            // parse_bundle_import with zero size/count
84                            // enforcement, even though the spec treats it as
85                            // an equally untrusted alternate ingestion path
86                            // to zip_base64 — bypassing every zip-bomb/DoS
87                            // defense the zip path enforces.
88                            let raw_files: Vec<crate::backend::bundle_import::BundleImportFile> = files
89                                .into_iter()
90                                .map(|f| crate::backend::bundle_import::BundleImportFile {
91                                    path: f.path,
92                                    content: f.content,
93                                })
94                                .collect();
95                            crate::backend::bundle_import::enforce_raw_files_caps(raw_files)
96                                .map_err(|e| format!("bundle.import: {e}"))?
97                        }
98                    };
99
100                let parsed = crate::backend::bundle_import::parse_bundle_import(&files)
101                    .map_err(|e| format!("bundle.import: {e}"))?;
102                warnings.extend(parsed.warnings.clone());
103
104                // Account requirement resolution (§4.5) — read-only lookup
105                // by provider, purely informational. Never creates an
106                // account, never writes anything derived from a match.
107                // Phase 3 spec §3.1: extracted into resolve_account_requirements,
108                // shared with the new preview/commit RPC handlers.
109                // Today's existing route keeps its response shape exactly
110                // as before -- unbounded, matching its own already-shipped
111                // behavior. The bounded-display projection below is a
112                // Phase 3 preview/commit-only requirement.
113                let resolved = resolve_account_requirements(&id_store, &parsed.requirements)
114                    .map_err(|e| format!("bundle.import: {e}"))?;
115                let resolved_requirement_ids: Vec<String> =
116                    resolved.iter().filter(|r| r.resolved()).map(|r| r.id.clone()).collect();
117                let unresolved_requirements: Vec<crate::backend::rpc_types::BundleImportUnresolvedRequirement> = resolved
118                    .iter()
119                    .filter(|r| !r.resolved())
120                    .map(|r| crate::backend::rpc_types::BundleImportUnresolvedRequirement {
121                        id: r.id.clone(),
122                        provider: r.provider.clone(),
123                        env: r.env.clone(),
124                        match_count: r.match_count,
125                    })
126                    .collect();
127
128                // Skills — global, not bound to any agent (mirrors
129                // skill.catalog.upsert's own is_global: true convention for
130                // a shared-resource creation path). A genuine name
131                // conflict (the ONLY intentional business error
132                // `skill_upsert_unique_global` returns —
133                // `StoreError::Other` containing "already exists") is a
134                // per-skill warning, not an aborted import. Codex P2, PR
135                // #2379: any OTHER error (locked/corrupt store, I/O
136                // failure) previously hit this same arm and was silently
137                // treated as a skip too — turning an infrastructure
138                // failure into an apparently-successful lossy import.
139                // Only the confirmed-conflict shape is swallowed; anything
140                // else aborts the whole RPC.
141                // codex P2, PR #2379 round 4: rollback previously discarded
142                // every skill_delete error via `let _ = ...`, so if the SAME
143                // failing store also rejects the cleanup deletes, previously-
144                // inserted global skills could silently survive an RPC that
145                // reports failure. No transaction primitive exists on Store
146                // today, so full atomicity is out of scope here; surfacing
147                // the rollback failure instead of swallowing it is the fix
148                // that fits — the caller at least learns cleanup itself may
149                // not have fully succeeded.
150                let rollback_skills = |ids: &[String]| -> Vec<String> {
151                    ids.iter()
152                        .filter_map(|id| mstore.skill_delete(&identity_store, id).err().map(|e| format!("{id}: {e}")))
153                        .collect()
154                };
155
156                let now = now_ms();
157                let mut imported_skill_ids: Vec<String> = Vec::new();
158                let mut skipped_skills = parsed.skipped_skills.clone();
159                for skill in &parsed.skills {
160                    let row = crate::backend::storage::Skill {
161                        id: uuid::Uuid::new_v4().to_string(),
162                        name: skill.slug.clone(),
163                        trigger: skill.slug.clone(),
164                        skill_type: crate::backend::agent_config::SKILL_TYPE_AGENT_SKILL.to_string(),
165                        description: skill.description.clone(),
166                        content: skill.content.clone(),
167                        is_global: true,
168                        created_at: now,
169                        updated_at: now,
170                    };
171                    match identity_store.skill_upsert_unique_global(&row) {
172                        Ok(()) => imported_skill_ids.push(row.id),
173                        Err(crate::backend::storage::error::StoreError::Other(msg))
174                            if msg.contains("already exists") =>
175                        {
176                            warnings.push(format!("skill \"{}\": {msg}", skill.slug));
177                            skipped_skills.push(skill.slug.clone());
178                        }
179                        Err(e) => {
180                            // Codex P2, PR #2379: roll back every skill this
181                            // loop already created before propagating —
182                            // otherwise an infra failure partway through
183                            // leaves orphaned global skill rows (bound to
184                            // no bundle, but visible/bindable by any agent)
185                            // behind a failed RPC.
186                            let rollback_errors = rollback_skills(&imported_skill_ids);
187                            let mut msg = format!(
188                                "bundle.import: failed to create skill \"{}\": {e}",
189                                skill.slug
190                            );
191                            if !rollback_errors.is_empty() {
192                                msg.push_str(&format!(
193                                    "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
194                                    rollback_errors.len(),
195                                    rollback_errors.join("; ")
196                                ));
197                            }
198                            return Err(msg);
199                        }
200                    }
201                }
202
203                let memory = Bundle {
204                    id: uuid::Uuid::new_v4().to_string(),
205                    name: parsed.name,
206                    description: parsed.description,
207                    is_blank: false,
208                    // Never imported as global — an import must not
209                    // silently start injecting into every agent's
210                    // CLAUDE.md without explicit user action (spec §4.4).
211                    is_global: false,
212                    provider: parsed.provider,
213                    model: parsed.model,
214                    instructions: parsed.instructions,
215                    // ABF v0.2 §2.2: every variant is stored verbatim, no
216                    // merge decision made at import time.
217                    instructions_by_provider: serde_json::to_string(&parsed.instructions_by_provider)
218                        .unwrap_or_else(|_| "{}".to_string()),
219                    // Phase 3 spec §3.0: ImportedContextFile gained a
220                    // stable `id` selection key alongside `path`/`content`
221                    // -- project it away before persisting, matching
222                    // `Bundle.context_files`'s existing documented
223                    // `[{path, content}]` shape.
224                    context_files: serde_json::to_string(
225                        &parsed.context_files.iter().map(|cf| json!({"path": cf.path, "content": cf.content})).collect::<Vec<_>>(),
226                    )
227                    .unwrap_or_else(|_| "[]".to_string()),
228                    // A bundle carries no MCP servers (see the warning below).
229                    mcp_servers: "[]".to_string(),
230                    skills: serde_json::to_string(&imported_skill_ids)
231                        .unwrap_or_else(|_| "[]".to_string()),
232                    sort_order: 0,
233                    created_at: now,
234                    updated_at: now,
235                    is_system: false,
236                };
237                if let Err(e) = id_store.bundle_upsert(&memory) {
238                    // Codex P2, PR #2379: same rollback as above — the
239                    // skills this RPC just created must not survive a
240                    // failed bundle creation as orphaned global rows.
241                    let rollback_errors = rollback_skills(&imported_skill_ids);
242                    let mut msg = format!("bundle.import: {e}");
243                    if !rollback_errors.is_empty() {
244                        msg.push_str(&format!(
245                            "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
246                            rollback_errors.len(),
247                            rollback_errors.join("; ")
248                        ));
249                    }
250                    return Err(msg);
251                }
252
253                // Ref tables are authoritative — bind after the bundle row
254                // exists, or this import exports empty.
255                warnings.extend(bind_imported_components(
256                    &mstore,
257                    &id_store,
258                    &identity_store,
259                    &memory.id,
260                    &imported_skill_ids,
261                ));
262                warnings.extend(mcp_servers_not_imported_warning(&parsed.mcp_servers));
263
264                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
265                if !imported_skill_ids.is_empty() {
266                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
267                }
268
269                Ok(BundleImportResponse {
270                    bundle_id: memory.id,
271                    imported_skill_ids,
272                    skipped_skills,
273                    resolved_requirement_ids,
274                    unresolved_requirements,
275                    warnings,
276                })
277            }
278        },
279    );
280}
281
282/// On-disk size ceiling for `file_path` input (Phase 3 spec §3.0.5, rounds
283/// 4/5) — generous headroom above `MAX_TOTAL_UNCOMPRESSED_BYTES`'s 50 MiB
284/// for compression/container overhead and imperfectly-compressed content.
285pub(super) const MAX_ABF_FILE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
286
287/// Reads a `file_path` input server-side (Phase 3 spec §3.0.5). Opens the
288/// path with a no-follow mechanism so a symlink fails to open at all
289/// rather than being silently resolved to its target (round 6) — one
290/// handle, one continuous read, no second path resolution for anything to
291/// race against (round 5's TOCTOU fix): metadata comes from that SAME open
292/// handle and is checked against `MAX_ABF_FILE_SIZE_BYTES` BEFORE any read
293/// (round 4); the actual read is still hard-bounded via `.take(...)`
294/// regardless of what the metadata claimed.
295pub(super) fn read_abf_file_path(path: &str) -> Result<Vec<u8>, String> {
296    use std::io::Read;
297
298    #[cfg(unix)]
299    let mut file = {
300        use std::os::unix::fs::OpenOptionsExt;
301        std::fs::OpenOptions::new()
302            .read(true)
303            .custom_flags(libc::O_NOFOLLOW)
304            .open(path)
305            .map_err(|e| format!("{path}: failed to open: {e}"))?
306    };
307    #[cfg(windows)]
308    let mut file = {
309        use std::os::windows::fs::OpenOptionsExt;
310        // FILE_FLAG_OPEN_REPARSE_POINT (0x0020_0000): opens a symlink/
311        // junction AS the reparse point itself, rather than transparently
312        // following it to its target — the Windows equivalent of Unix's
313        // O_NOFOLLOW, in a single atomic CreateFileW call (no separate
314        // path resolution for a TOCTOU window to open in). The metadata
315        // check immediately below then rejects the handle outright if it
316        // turns out to be a reparse point. Windows symlinks require
317        // elevated privileges to create by default, narrowing (not
318        // eliminating) the practical risk this guards against.
319        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
320        std::fs::OpenOptions::new()
321            .read(true)
322            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
323            .open(path)
324            .map_err(|e| format!("{path}: failed to open: {e}"))?
325    };
326    #[cfg(not(any(unix, windows)))]
327    let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: failed to open: {e}"))?;
328
329    let metadata = file.metadata().map_err(|e| format!("{path}: failed to stat: {e}"))?;
330    #[cfg(windows)]
331    if metadata.file_type().is_symlink() {
332        return Err(format!("{path}: refusing to follow a symlink"));
333    }
334    if !metadata.is_file() {
335        return Err(format!("{path}: not a regular file"));
336    }
337    if metadata.len() > MAX_ABF_FILE_SIZE_BYTES {
338        return Err(format!(
339            "{path}: size ({} bytes) exceeds the limit ({MAX_ABF_FILE_SIZE_BYTES} bytes)",
340            metadata.len()
341        ));
342    }
343
344    let mut buf: Vec<u8> = Vec::new();
345    let mut limited = (&mut file).take(MAX_ABF_FILE_SIZE_BYTES + 1);
346    limited.read_to_end(&mut buf).map_err(|e| format!("{path}: failed to read: {e}"))?;
347    if buf.len() as u64 > MAX_ABF_FILE_SIZE_BYTES {
348        return Err(format!("{path}: exceeds the size limit while reading"));
349    }
350    Ok(buf)
351}
352
353/// Bounded warning budget shared by `bundle.import.preview`/`.commit`
354/// (Phase 3 spec §3.1, round 11) — unlike the existing `bundle.import`
355/// route's effectively-unbounded budget, preserved unchanged above.
356pub(super) fn preview_commit_warning_budget() -> crate::backend::bundle_import::WarningBudget {
357    crate::backend::bundle_import::WarningBudget::bounded(200, 300)
358}
359
360/// Final combined-list bound applied to a preview/commit response's
361/// `warnings` (Phase 3 spec §3.1 round 8 / round 9's commit-response
362/// generalization) — cheap defense-in-depth over an already
363/// per-source-bounded list (each of `resolve_import_input`'s intake
364/// warnings and `parse_bundle_import_with_budget`'s own warnings is
365/// already individually bounded; concatenating two independently-bounded
366/// lists can still exceed either bound alone, so one more pass caps the
367/// combined total before it's ever serialized).
368/// Per-entry content cap for `project_instructions` in an import preview.
369///
370/// Deliberately far below `MAX_INSTRUCTIONS_PREVIEW_CHARS` (50k): that cap
371/// applies to ONE field, whereas a preview can carry up to
372/// `MAX_IMPORTED_PROJECT_INSTRUCTIONS` (200) of these, so the same number
373/// would put a 10 MB ceiling on a single response. 4k is enough to recognize a
374/// file and see how it opens; the untruncated bytes are in the archive itself.
375pub(super) const MAX_PROJECT_INSTRUCTION_PREVIEW_CHARS: usize = 4_000;
376
377pub(super) fn bound_warnings_for_response(warnings: Vec<String>) -> (Vec<String>, bool) {
378    const MAX_COMBINED_WARNINGS: usize = 200;
379    if warnings.len() <= MAX_COMBINED_WARNINGS {
380        (warnings, false)
381    } else {
382        let mut kept: Vec<String> = warnings.into_iter().take(MAX_COMBINED_WARNINGS).collect();
383        kept.push("... additional warnings not shown".to_string());
384        (kept, true)
385    }
386}
387
388/// The outcome of resolving one of `file_path`/`zip_base64`/`files` into
389/// intake-ready files plus the Phase 3 content digest (§3.0.5) — shared by
390/// `bundle.import.preview` and `.commit` so both compute the digest
391/// identically. The mode tag baked into `content_digest` (round 7) means a
392/// plain digest-equality check at commit is sufficient to enforce "same
393/// input mode as preview" (round 6) — no separate mode field is needed.
394#[derive(Debug)]
395pub(super) struct ResolvedImportInput {
396    pub(crate) files: Vec<crate::backend::bundle_import::BundleImportFile>,
397    pub(crate) intake_warnings: Vec<String>,
398    pub(crate) content_digest: String,
399    /// The archive's widgets (`widgets/<id>/`, read as bytes), out of
400    /// `files` and its warnings (SPEC_WIDGET_SHARING_2026_10_10.md §3).
401    pub(crate) widgets: Vec<crate::backend::bundle_widgets::BundleWidget>,
402}
403
404/// Takes a zip's widget entries out of the text files and their warnings,
405/// and reads them as bytes instead.
406fn split_widgets(
407    raw: &[u8],
408    files: &mut Vec<crate::backend::bundle_import::BundleImportFile>,
409    warnings: &mut Vec<String>,
410) -> Result<Vec<crate::backend::bundle_widgets::BundleWidget>, String> {
411    use crate::backend::bundle_widgets as bw;
412    files.retain(|f| !bw::is_widget_path(&f.path));
413    warnings.retain(|w| !bw::is_widget_warning(w));
414    bw::read_from_zip(raw)
415}
416
417pub(super) fn resolve_import_input(
418    file_path: Option<String>,
419    zip_base64: Option<String>,
420    files: Option<Vec<FileEntry>>,
421    warning_budget: crate::backend::bundle_import::WarningBudget,
422) -> Result<ResolvedImportInput, String> {
423    use crate::backend::bundle_import as bi;
424    let provided = [file_path.is_some(), zip_base64.is_some(), files.is_some()]
425        .iter()
426        .filter(|p| **p)
427        .count();
428    if provided != 1 {
429        return Err(format!(
430            "exactly one of file_path/zip_base64/files must be present, got {provided}"
431        ));
432    }
433    if let Some(path) = file_path {
434        let raw = read_abf_file_path(&path)?;
435        let content_digest = bi::content_digest_raw_bytes(bi::ImportInputMode::FilePath, &raw);
436        let (mut files, mut intake_warnings) = bi::unzip_bundle_import_with_budget(&raw, warning_budget)?;
437        let widgets = split_widgets(&raw, &mut files, &mut intake_warnings)?;
438        return Ok(ResolvedImportInput { files, intake_warnings, content_digest, widgets });
439    }
440    if let Some(b64) = zip_base64 {
441        use base64::Engine as _;
442        let raw = base64::engine::general_purpose::STANDARD
443            .decode(&b64)
444            .map_err(|e| format!("invalid zip_base64: {e}"))?;
445        let content_digest = bi::content_digest_raw_bytes(bi::ImportInputMode::ZipBase64, &raw);
446        let (mut files, mut intake_warnings) = bi::unzip_bundle_import_with_budget(&raw, warning_budget)?;
447        let widgets = split_widgets(&raw, &mut files, &mut intake_warnings)?;
448        return Ok(ResolvedImportInput { files, intake_warnings, content_digest, widgets });
449    }
450    let files = files.expect("exactly one input already validated present");
451    let raw_files: Vec<bi::BundleImportFile> = files
452        .into_iter()
453        .map(|f| bi::BundleImportFile { path: f.path, content: f.content })
454        .collect();
455    let (capped_files, intake_warnings) = bi::enforce_raw_files_caps_with_budget(raw_files, warning_budget)?;
456    let content_digest = bi::content_digest_files(&capped_files);
457    // Text files can't carry a widget's bytes: widgets come only in a zip.
458    Ok(ResolvedImportInput { files: capped_files, intake_warnings, content_digest, widgets: vec![] })
459}
460
461/// `bundle.import.preview` — Phase 3 of
462/// docs/specs/SPEC_ABF_IMPORT_UI_PHASE3_2026_08_02.md §3.1. Pure parse plus
463/// read-only collision/name-collision lookups — zero Store writes. Window-
464/// scoped like the rest of `bundle.*`. Extracted from the RPC closure into
465/// a directly-callable, directly-testable function (the closure itself
466/// only deserializes the request and forwards).
467pub(super) async fn bundle_import_preview_impl(
468    id_store: &crate::backend::storage::store::Store,
469    identity_store: &crate::backend::storage::store::Store,
470    mstore: &crate::backend::storage::store::Store,
471    req: PreviewReq,
472) -> Result<crate::backend::rpc_types::BundleImportPreviewResponse, String> {
473    use crate::backend::bundle_import as bi;
474    use crate::backend::rpc_types::{
475        BundleImportContextFilePreview, BundleImportMcpServerDisplay,
476        BundleImportMcpServerPreview, BundleImportPreviewResponse,
477        BundleImportProjectInstructionPreview, BundleImportRequirementPreview,
478        BundleImportSkillPreview,
479    };
480
481    let resolved = resolve_import_input(req.file_path, req.zip_base64, req.files, preview_commit_warning_budget())
482        .map_err(|e| format!("bundle.import.preview: {e}"))?;
483
484    let parsed = bi::parse_bundle_import_with_budget(&resolved.files, preview_commit_warning_budget())
485        .map_err(|e| format!("bundle.import.preview: {e}"))?;
486
487    let widgets = preview_widgets(&resolved);
488    let mut all_warnings = resolved.intake_warnings;
489    all_warnings.extend(parsed.warnings);
490
491    // Skill collision detection (§3.1, two-pass).
492    let global_slugs: std::collections::HashSet<String> = mstore
493        .skill_list_global(identity_store)
494        .map_err(|e| format!("bundle.import.preview: {e}"))?
495        .into_iter()
496        .map(|item| item.skill.name)
497        .collect();
498    let in_bundle_dupes = bi::duplicate_in_bundle_slugs(&parsed.skills);
499
500    let skills_json: Vec<BundleImportSkillPreview> = parsed
501        .skills
502        .iter()
503        .map(|skill| {
504            let collision = bi::classify_skill_collision(&skill.slug, &global_slugs, &in_bundle_dupes);
505            BundleImportSkillPreview {
506                source_dir: skill.source_dir.clone(),
507                slug: bounded_display(&skill.slug),
508                description: bounded_display(&skill.description),
509                collision: collision.to_string(),
510            }
511        })
512        .collect();
513
514    let mcp_servers_json: Vec<BundleImportMcpServerPreview> = parsed
515        .mcp_servers
516        .iter()
517        .map(|m| {
518            // `mcp_server_display` still returns a Value (it is also used
519            // elsewhere and has its own tests asserting it never leaks the full
520            // config); read its two known keys back into the typed shape rather
521            // than changing that function's contract here.
522            let display = bi::mcp_server_display(&m.config);
523            BundleImportMcpServerPreview {
524                source_path: m.source_path.clone(),
525                display: BundleImportMcpServerDisplay {
526                    name: display.get("name").and_then(|v| v.as_str()).map(str::to_string),
527                    command: display.get("command").and_then(|v| v.as_str()).map(str::to_string),
528                },
529            }
530        })
531        .collect();
532
533    let (instructions_preview, instructions_truncated, instructions_total_chars) =
534        bi::bounded_instructions_preview(&parsed.instructions);
535
536    let context_files_json: Vec<BundleImportContextFilePreview> = parsed
537        .context_files
538        .iter()
539        .map(|cf| BundleImportContextFilePreview {
540            id: cf.id,
541            display_path: bounded_display(&cf.path),
542            size_bytes: cf.content.len(),
543        })
544        .collect();
545
546    let resolved_requirements = resolve_account_requirements(id_store, &parsed.requirements)
547        .map_err(|e| format!("bundle.import.preview: {e}"))?;
548    let requirements_json: Vec<BundleImportRequirementPreview> = resolved_requirements
549        .iter()
550        .map(|r| BundleImportRequirementPreview {
551            id: bounded_display(&r.id),
552            provider: bounded_display(&r.provider),
553            env: bounded_display(&r.env),
554            resolved: r.resolved(),
555            match_count: r.match_count,
556        })
557        .collect();
558
559    // Bundle name collision -- soft, informational (§2: bundle_upsert
560    // has no name uniqueness constraint, so this never blocks).
561    let existing_names: std::collections::HashSet<String> = id_store
562        .bundle_list()
563        .map_err(|e| format!("bundle.import.preview: {e}"))?
564        .into_iter()
565        .map(|b| b.name)
566        .collect();
567    let name_collision = existing_names.contains(&parsed.name);
568
569    // Phase 3, Codex P1 on PR #3163. Read-only: this is what the SOURCE agent
570    // was reading, and `bundle.import.commit` has no counterpart field — the
571    // entries exist to be looked at, never to be written into the importing
572    // repository (spec §5.2). Each content is bounded the same way
573    // `instructions_preview` is, since a snapshot can be an arbitrarily large
574    // repository file and the response carries up to
575    // MAX_IMPORTED_PROJECT_INSTRUCTIONS of them.
576    let project_instructions_json: Vec<BundleImportProjectInstructionPreview> = parsed
577        .project_instructions
578        .iter()
579        .map(|pi| {
580            let total_chars = pi.content.chars().count();
581            let truncated = total_chars > MAX_PROJECT_INSTRUCTION_PREVIEW_CHARS;
582            let preview: String = if truncated {
583                pi.content.chars().take(MAX_PROJECT_INSTRUCTION_PREVIEW_CHARS).collect()
584            } else {
585                pi.content.clone()
586            };
587            BundleImportProjectInstructionPreview {
588                path: bounded_display(&pi.path),
589                file: bounded_display(&pi.file),
590                content_hash: bounded_display(&pi.content_hash),
591                owner: bounded_display(&pi.owner),
592                content_preview: preview,
593                content_truncated: truncated,
594                content_total_chars: total_chars,
595            }
596        })
597        .collect();
598
599    let (warnings, warnings_truncated) = bound_warnings_for_response(all_warnings);
600
601    Ok(BundleImportPreviewResponse {
602        widgets,
603        project_instructions: project_instructions_json,
604        name: parsed.name,
605        description: bounded_display(&parsed.description),
606        instructions_preview,
607        instructions_truncated,
608        instructions_total_chars,
609        context_files: context_files_json,
610        skills: skills_json,
611        mcp_servers: mcp_servers_json,
612        requirements: requirements_json,
613        warnings,
614        warnings_truncated,
615        name_collision,
616        content_digest: resolved.content_digest,
617    })
618}
619
620pub(super) fn register_bundle_import_preview(engine: &Arc<WshRpcEngine>, state: &AppState) {
621    let id_store = state.id_store.clone();
622    let identity_store = state.identity_store.clone();
623    let mstore = state.mstore.clone();
624    engine.register_typed(
625        COMMAND_BUNDLE_IMPORT_PREVIEW,
626        move |req: PreviewReq, _ctx| {
627            let id_store = id_store.clone();
628            let identity_store = identity_store.clone();
629            let mstore = mstore.clone();
630            async move {
631                bundle_import_preview_impl(&id_store, &identity_store, &mstore, req).await
632            }
633        },
634    );
635}
636
637
638
639
640/// `bundle.import.commit` — Phase 3 §3.2. Re-resolves and re-parses the
641/// same input fresh (never trusts client-supplied preview data for
642/// anything written), rejects on a content-digest mismatch, then writes
643/// only the selected items. Shares `bundle.import`'s existing skill-write/
644/// rollback logic; the differences are selection filtering, the
645/// `bundle_name`/`import_as` overrides, and the digest gate. Extracted
646/// from the RPC closure into a directly-callable, directly-testable
647/// function (the closure itself only deserializes the request and
648/// forwards).
649pub(super) async fn bundle_import_commit_impl(
650    id_store: &crate::backend::storage::store::Store,
651    identity_store: &crate::backend::storage::store::Store,
652    mstore: &crate::backend::storage::store::Store,
653    broker: &crate::backend::mps::Broker,
654    req: CommitReq,
655) -> Result<crate::backend::rpc_types::BundleImportCommitResponse, String> {
656    use crate::backend::bundle_import as bi;
657    use crate::backend::rpc_types::{BundleImportCommitResponse, BundleImportUnresolvedRequirement};
658
659                let resolved = resolve_import_input(req.file_path, req.zip_base64, req.files, preview_commit_warning_budget())
660                    .map_err(|e| format!("bundle.import.commit: {e}"))?;
661
662                // §3.0.5: reject BEFORE doing anything else on a mismatch --
663                // never a partial import against whatever content was
664                // actually given. The mode tag baked into content_digest
665                // (round 7) means this single comparison also enforces
666                // "same input mode as preview" (round 6) with no separate
667                // mode field needed.
668                if resolved.content_digest != req.expected_content_digest {
669                    return Err(
670                        "bundle.import.commit: content changed since preview (digest mismatch) — re-select and preview again"
671                            .to_string(),
672                    );
673                }
674
675                let parsed = bi::parse_bundle_import_with_budget(&resolved.files, preview_commit_warning_budget())
676                    .map_err(|e| format!("bundle.import.commit: {e}"))?;
677
678                let mut warnings = resolved.intake_warnings;
679                warnings.extend(parsed.warnings.clone());
680
681                // codex P2, PR #2381 round 11: bundle_name must actually be
682                // substituted for parsed.name, never silently ignored.
683                // reagentx P2, PR #2382 round 3: unlike parsed.name (bounded
684                // at parse time), a client-supplied override had no length
685                // cap at all -- bound_bundle_name closes that gap the same
686                // way for both paths (a no-op when bundle_name is already
687                // parsed.name, since that's already within the cap).
688                let bundle_name = bi::bound_bundle_name(&req.bundle_name.unwrap_or_else(|| parsed.name.clone()));
689
690                let instructions = if req.include_instructions { parsed.instructions.clone() } else { String::new() };
691                // ABF v0.2 §2.2: provider-scoped variants are part of the
692                // same "instructions" component, gated by the same
693                // include_instructions flag — no separate per-variant
694                // selection exists in the Phase 3 preview/commit UI.
695                let instructions_by_provider = if req.include_instructions {
696                    parsed.instructions_by_provider.clone()
697                } else {
698                    std::collections::HashMap::new()
699                };
700
701                // include_context_files selects by the stable `id` (round
702                // 13), never by (truncatable) display_path.
703                let selected_context_files: Vec<serde_json::Value> = parsed
704                    .context_files
705                    .iter()
706                    .filter(|cf| req.include_context_files.contains(&cf.id))
707                    .map(|cf| json!({ "path": cf.path, "content": cf.content }))
708                    .collect();
709
710                let resolved_reqs = resolve_account_requirements(id_store, &parsed.requirements)
711                    .map_err(|e| format!("bundle.import.commit: {e}"))?;
712                // codex P2, PR #2381 round 12: commit's response reuses the
713                // exact same bounded-display projection preview's
714                // equivalent fields use, via the shared bounded_display fn.
715                let resolved_requirement_ids: Vec<String> =
716                    resolved_reqs.iter().filter(|r| r.resolved()).map(|r| bounded_display(&r.id)).collect();
717                let unresolved_requirements: Vec<BundleImportUnresolvedRequirement> = resolved_reqs
718                    .iter()
719                    .filter(|r| !r.resolved())
720                    .map(|r| BundleImportUnresolvedRequirement {
721                        id: bounded_display(&r.id),
722                        provider: bounded_display(&r.provider),
723                        env: bounded_display(&r.env),
724                        match_count: r.match_count,
725                    })
726                    .collect();
727
728                // Two-pass skill collision, recomputed server-side --
729                // §4.1 point 4's "empty rename on a colliding skill = skip"
730                // rule is enforced authoritatively here, not left to a
731                // possibly-buggy/malicious client to have honored: without
732                // this, a "duplicate_in_bundle" collision (not yet in the
733                // global catalog) would let the FIRST of two same-slug
734                // skills write successfully under its own slug even with
735                // an empty rename, since skill_upsert_unique_global alone
736                // wouldn't reject it until the second attempt.
737                let global_slugs: std::collections::HashSet<String> = mstore
738                    .skill_list_global(identity_store)
739                    .map_err(|e| format!("bundle.import.commit: {e}"))?
740                    .into_iter()
741                    .map(|item| item.skill.name)
742                    .collect();
743                let in_bundle_dupes = bi::duplicate_in_bundle_slugs(&parsed.skills);
744
745                let rollback_skills = |ids: &[String]| -> Vec<String> {
746                    ids.iter()
747                        .filter_map(|id| mstore.skill_delete(identity_store, id).err().map(|e| format!("{id}: {e}")))
748                        .collect()
749                };
750
751                let now = now_ms();
752                let mut imported_skill_ids: Vec<String> = Vec::new();
753                let mut skipped_skills: Vec<String> = Vec::new();
754
755                // reagentx P1, PR #2382 round 3: req.include_skills is
756                // client-supplied with no length cap and no dedup by
757                // source_dir -- an unbounded/repeated array could otherwise
758                // drive an unbounded number of skill_upsert_unique_global
759                // Store writes regardless of how many skills the archive
760                // itself actually parsed to (parsed.skills is already
761                // bounded by MAX_IMPORTED_SKILLS). First occurrence wins per
762                // source_dir (matches this module's existing duplicate-
763                // reference convention elsewhere), then capped at the same
764                // MAX_IMPORTED_SKILLS the parser itself enforces.
765                let mut seen_source_dirs: std::collections::HashSet<&str> = std::collections::HashSet::new();
766                let deduped_skill_selections: Vec<&SkillSelection> = req
767                    .include_skills
768                    .iter()
769                    .filter(|s| seen_source_dirs.insert(s.source_dir.as_str()))
770                    .take(bi::MAX_IMPORTED_SKILLS)
771                    .collect();
772
773                for selection in deduped_skill_selections {
774                    let Some(skill) = parsed.skills.iter().find(|s| s.source_dir == selection.source_dir) else {
775                        continue; // source_dir not present in this parse -- nothing to import
776                    };
777                    let collision = bi::classify_skill_collision(&skill.slug, &global_slugs, &in_bundle_dupes);
778                    let non_empty_rename = selection.import_as.as_deref().filter(|s| !s.is_empty());
779
780                    if collision != "none" && non_empty_rename.is_none() {
781                        // §4.1 point 4: never silently sent through with
782                        // its original, known-conflicting slug.
783                        skipped_skills.push(bounded_display(&skill.slug));
784                        continue;
785                    }
786                    let effective_slug = non_empty_rename.map(|s| s.to_string()).unwrap_or_else(|| skill.slug.clone());
787
788                    let row = crate::backend::storage::Skill {
789                        id: uuid::Uuid::new_v4().to_string(),
790                        name: effective_slug.clone(),
791                        trigger: effective_slug.clone(),
792                        skill_type: crate::backend::agent_config::SKILL_TYPE_AGENT_SKILL.to_string(),
793                        description: skill.description.clone(),
794                        content: skill.content.clone(),
795                        is_global: true,
796                        created_at: now,
797                        updated_at: now,
798                    };
799                    match identity_store.skill_upsert_unique_global(&row) {
800                        Ok(()) => imported_skill_ids.push(row.id),
801                        Err(crate::backend::storage::error::StoreError::Other(msg))
802                            if msg.contains("already exists") =>
803                        {
804                            // codex P2, PR #2382 round 2: effective_slug can be
805                            // the caller-supplied import_as, which has no
806                            // length bound anywhere before this point (unlike
807                            // a parsed skill.slug, implicitly bounded by the
808                            // per-entry decompression cap) -- and msg's own
809                            // text embeds the identical unbounded value a
810                            // second time (StoreError::Other's own format!
811                            // interpolates skill.name). Use a fixed message
812                            // instead of msg's text (its only informative
813                            // content, "already exists", is already implied by
814                            // the branch guard) and the shared bounded_display
815                            // projection every other warning in this handler
816                            // already uses, closing both unbounded paths at
817                            // once rather than truncating msg's text in place.
818                            warnings.push(format!("skill \"{}\": already exists", bounded_display(&effective_slug)));
819                            skipped_skills.push(bounded_display(&effective_slug));
820                        }
821                        Err(e) => {
822                            let rollback_errors = rollback_skills(&imported_skill_ids);
823                            let mut msg = format!(
824                                "bundle.import.commit: failed to create skill \"{effective_slug}\": {e}"
825                            );
826                            if !rollback_errors.is_empty() {
827                                msg.push_str(&format!(
828                                    "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
829                                    rollback_errors.len(),
830                                    rollback_errors.join("; ")
831                                ));
832                            }
833                            return Err(msg);
834                        }
835                    }
836                }
837
838                let memory = Bundle {
839                    id: uuid::Uuid::new_v4().to_string(),
840                    name: bundle_name,
841                    description: parsed.description,
842                    is_blank: false,
843                    is_global: false,
844                    // Like `description` above (and unlike instructions and
845                    // context files), provider/model are structural identity
846                    // fields, not opt-in components — there's no
847                    // include_provider toggle in the preview/commit UI, so
848                    // these always carry straight through when present.
849                    provider: parsed.provider,
850                    model: parsed.model,
851                    instructions,
852                    instructions_by_provider: serde_json::to_string(&instructions_by_provider)
853                        .unwrap_or_else(|_| "{}".to_string()),
854                    context_files: serde_json::to_string(&selected_context_files)
855                        .unwrap_or_else(|_| "[]".to_string()),
856                    mcp_servers: "[]".to_string(),
857                    skills: serde_json::to_string(&imported_skill_ids)
858                        .unwrap_or_else(|_| "[]".to_string()),
859                    sort_order: 0,
860                    created_at: now,
861                    updated_at: now,
862                    is_system: false,
863                };
864                if let Err(e) = id_store.bundle_upsert(&memory) {
865                    let rollback_errors = rollback_skills(&imported_skill_ids);
866                    let mut msg = format!("bundle.import.commit: {e}");
867                    if !rollback_errors.is_empty() {
868                        msg.push_str(&format!(
869                            "; additionally, rollback of {} previously-created skill(s) failed and may have left orphaned global skill row(s): {}",
870                            rollback_errors.len(),
871                            rollback_errors.join("; ")
872                        ));
873                    }
874                    return Err(msg);
875                }
876
877                // Ref tables are authoritative — bind after the bundle row
878                // exists.
879                warnings.extend(bind_imported_components(
880                    &mstore,
881                    &id_store,
882                    &identity_store,
883                    &memory.id,
884                    &imported_skill_ids,
885                ));
886                warnings.extend(mcp_servers_not_imported_warning(&parsed.mcp_servers));
887
888                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
889                if !imported_skill_ids.is_empty() {
890                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
891                }
892
893                // codex P1, PR #2381 round 9: bounded the same as preview's
894                // response, not left unbounded like today's bundle.import.
895                let (bounded_warnings, warnings_truncated) = bound_warnings_for_response(warnings);
896
897    Ok(BundleImportCommitResponse {
898        widgets: vec![],
899        bundle_id: memory.id,
900        imported_skill_ids,
901        skipped_skills,
902        resolved_requirement_ids,
903        unresolved_requirements,
904        warnings: bounded_warnings,
905        warnings_truncated,
906    })
907}
908
909pub(super) fn register_bundle_import_commit(engine: &Arc<WshRpcEngine>, state: &AppState) {
910    let id_store = state.id_store.clone();
911    let identity_store = state.identity_store.clone();
912    let mstore = state.mstore.clone();
913    let broker = state.broker.clone();
914    let app = state.clone();
915    engine.register_typed(
916        COMMAND_BUNDLE_IMPORT_COMMIT,
917        move |req: CommitReq, _ctx| {
918            let id_store = id_store.clone();
919            let identity_store = identity_store.clone();
920            let mstore = mstore.clone();
921            let broker = broker.clone();
922            let app = app.clone();
923            async move {
924                let widgets_input = (!req.include_widgets.is_empty())
925                    .then(|| (req.file_path.clone(), req.zip_base64.clone(), req.expected_content_digest.clone(), req.include_widgets.clone()));
926                let mut resp = bundle_import_commit_impl(&id_store, &identity_store, &mstore, &broker, req).await?;
927                if let Some((file_path, zip_base64, digest, include)) = widgets_input {
928                    resp.widgets = install_bundle_widgets(&app, file_path, zip_base64, &digest, &include).await;
929                }
930                Ok(resp)
931            }
932        },
933    );
934}
935
936/// The import preview's line for each widget the bundle carries.
937fn preview_widgets(resolved: &ResolvedImportInput) -> Vec<crate::backend::bundle_widgets::BundleImportWidgetPreview> {
938    use crate::backend::bundle_widgets as bw;
939    if resolved.widgets.is_empty() {
940        return vec![];
941    }
942    let manifest = resolved.files.iter().find(|f| f.path == crate::backend::bundle_import::MANIFEST_FILE).map(|f| f.content.as_str());
943    let declared = bw::declared(manifest);
944    let svc = crate::backend::widget_packages::service();
945    let installed = svc.map(|s| s.list()).unwrap_or_default();
946    resolved
947        .widgets
948        .iter()
949        .map(|w| {
950            bw::preview(w, &declared, &installed, |id, signer| match svc {
951                Some(s) => s.describe_signature(id, signer),
952                None => crate::backend::widget_signature::describe(id, signer, &Default::default()),
953            })
954        })
955        .collect()
956}
957
958/// After a commit: copy in the chosen widgets and ask the user to approve
959/// each, the way an agent's `WidgetInstall` does. The archive is read again
960/// and held to the digest the user previewed, so what installs is what they
961/// saw (SPEC_WIDGET_SHARING_2026_10_10.md §3.3). A bundle never approves a
962/// widget.
963async fn install_bundle_widgets(
964    app: &AppState,
965    file_path: Option<String>,
966    zip_base64: Option<String>,
967    digest: &str,
968    include: &[String],
969) -> Vec<crate::backend::rpc_types::BundleImportWidgetResult> {
970    use crate::backend::bundle_widgets as bw;
971    use crate::backend::rpc_types::BundleImportWidgetResult;
972    use crate::backend::widget_packages::{self as wp, WidgetState};
973    let failed = |id: &str, e: String| BundleImportWidgetResult { id: id.to_string(), status: "failed".into(), error: Some(e) };
974    let Some(svc) = wp::service() else {
975        return include.iter().map(|id| failed(id, "widget packages aren't available on this AgentMux".into())).collect();
976    };
977    let resolved = match tokio::task::spawn_blocking(move || resolve_import_input(file_path, zip_base64, None, preview_commit_warning_budget())).await {
978        Ok(Ok(r)) if r.content_digest == digest => r,
979        Ok(Ok(_)) => return include.iter().map(|id| failed(id, "the bundle changed since the preview".into())).collect(),
980        Ok(Err(e)) => return include.iter().map(|id| failed(id, e.clone())).collect(),
981        Err(e) => return include.iter().map(|id| failed(id, e.to_string())).collect(),
982    };
983    let previews = preview_widgets(&resolved);
984    let name = resolved
985        .files
986        .iter()
987        .find(|f| f.path == crate::backend::bundle_import::MANIFEST_FILE)
988        .and_then(|f| serde_json::from_str::<serde_json::Value>(&f.content).ok())
989        .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
990        .unwrap_or_else(|| "a bundle".into());
991    let mut results = Vec::new();
992    let mut asking = Vec::new();
993    for id in include {
994        let (Some(w), Some(p)) = (resolved.widgets.iter().find(|w| &w.id == id), previews.iter().find(|p| &p.id == id)) else {
995            results.push(failed(id, "the bundle doesn't carry it".into()));
996            continue;
997        };
998        if let Some(e) = &p.error {
999            results.push(failed(id, e.clone()));
1000            continue;
1001        }
1002        if p.same_as_installed {
1003            results.push(BundleImportWidgetResult { id: id.clone(), status: "unchanged".into(), error: None });
1004            continue;
1005        }
1006        let (dir, widget) = (svc.widgets_dir.clone(), w.clone());
1007        match tokio::task::spawn_blocking(move || bw::install(&dir, &widget)).await {
1008            Ok(Ok(_)) => {
1009                asking.push(id.clone());
1010                results.push(BundleImportWidgetResult { id: id.clone(), status: "waiting".into(), error: None });
1011            }
1012            Ok(Err(e)) => results.push(failed(id, e)),
1013            Err(e) => results.push(failed(id, e.to_string())),
1014        }
1015    }
1016    if !asking.is_empty() {
1017        let packages = wp::refresh_off_thread(&app.config_watcher, &app.event_bus, &app.broker).await;
1018        let asker = format!("The bundle \u{201c}{name}\u{201d}");
1019        for pkg in packages.iter().filter(|p| asking.contains(&p.id) && p.state != WidgetState::Approved) {
1020            // Nobody waits on the answer: the prompt is the user's, and the
1021            // widget stays in Settings → Widgets until they answer.
1022            drop(crate::backend::widget_requests::requests().ask(crate::backend::widget_requests::WidgetInstallRequest::of(pkg, &asker)));
1023        }
1024        crate::server::widget_agent_handlers::publish_requests(app);
1025    }
1026    results
1027}