Skip to main content

agentmux_srv/server/app_api/bundle/
components.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Component plumbing shared by the per-agent export and import paths:
5//! purging dangling component refs, resolving a bundle's components to their
6//! backing rows, and splicing history/instructions/memory into a manifest.
7
8use super::*;
9
10/// Drop a deleted bundle's component refs.
11///
12/// Called from **both** delete paths — `bundle.delete` here and `deletememory`
13/// in `agent_handlers/bundle.rs`, which is the one Memory actually calls.
14/// Fixing only the first left the product path orphaning refs (Codex, PR
15/// #3153), so this exists to make "both" mean one implementation.
16///
17/// `bundle_delete` runs on the store that owns `db_bundles`, which has no ref
18/// tables — they live in the channel store, a different database, which is
19/// also why no foreign key reaches across (the `db_bundle_skills_ref` comment
20/// in `migrations.rs`'s `run_object_schema`). So the purge cannot live inside
21/// it and has to be driven from a handler, where both stores are in scope.
22///
23/// Best-effort by design: the bundle row is already gone by the time this
24/// runs, so a failure here must not turn a successful delete into an error.
25pub(crate) fn purge_bundle_component_refs(
26    mstore: &crate::backend::storage::store::Store,
27    bundle_id: &str,
28) {
29    match mstore.bundle_unbind_all_components(bundle_id) {
30        Ok((skills, mcp)) if skills + mcp > 0 => {
31            tracing::info!(bundle_id, skills, mcp, "bundle delete: purged component refs")
32        }
33        Ok(_) => {}
34        Err(e) => tracing::warn!(
35            bundle_id, error = %e,
36            "bundle delete: component refs left behind"
37        ),
38    }
39    // And out of this channel's agents' Bundles lists; a list on another
40    // channel skips it at launch.
41    if let Err(e) = mstore.agent_bundles_forget_bundle(bundle_id) {
42        tracing::warn!(bundle_id, error = %e, "bundle delete: left in agents' Bundles lists");
43    }
44}
45
46/// Bind an imported bundle's skills into the ref table.
47///
48/// Phase 0b. The ref tables are authoritative for what a bundle contains, so
49/// an import that only wrote the inline column would produce a bundle that
50/// exports empty.
51///
52/// **Must run after `bundle_upsert`.** The bind checks the bundle exists in
53/// `id_store` first (`managed_bind_bundle` in `storage/managed.rs`) and
54/// refuses otherwise.
55///
56/// Returns warnings rather than failing: by this point the bundle row is
57/// already committed, so aborting would leave a half-imported bundle behind. A
58/// component that could not be bound is reported and the import continues.
59pub(super) fn bind_imported_components(
60    mstore: &crate::backend::storage::store::Store,
61    id_store: &crate::backend::storage::store::Store,
62    identity_store: &crate::backend::storage::store::Store,
63    bundle_id: &str,
64    imported_skill_ids: &[String],
65) -> Vec<String> {
66    let mut warnings: Vec<String> = Vec::new();
67
68    for skill_id in imported_skill_ids {
69        if let Err(e) = mstore.bundle_skill_bind(identity_store, id_store, bundle_id, skill_id) {
70            warnings.push(format!(
71                "skills: imported skill {skill_id} could not be bound to the bundle ({e}) — it will not be exported or reach the agent"
72            ));
73        }
74    }
75
76    warnings
77}
78
79/// The warning an import gives for the MCP servers an archive carries. A
80/// bundle carries no MCP servers, so none is imported; they're named so the
81/// user can add them in Connectors
82/// (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1). `None` when
83/// the archive has none.
84pub(crate) fn mcp_servers_not_imported_warning(
85    servers: &[crate::backend::bundle_import::ParsedMcpServer],
86) -> Option<String> {
87    if servers.is_empty() {
88        return None;
89    }
90    let names: Vec<String> = servers
91        .iter()
92        .map(|m| {
93            crate::backend::bundle_import::mcp_server_display(&m.config)
94                .get("name")
95                .and_then(|v| v.as_str())
96                .map(str::to_string)
97                .unwrap_or_else(|| m.source_path.clone())
98        })
99        .collect();
100    Some(format!(
101        "mcpServers: not imported ({}). Bundles don't carry MCP servers; add them in Connectors",
102        names.join(", ")
103    ))
104}
105
106/// A bundle's components, resolved from the tables that are authoritative for
107/// them.
108pub(crate) struct ResolvedComponents {
109    pub(crate) skills: Vec<crate::backend::storage::Skill>,
110}
111
112/// Resolve what a bundle actually contains, from `db_bundle_skills_ref`.
113///
114/// Phase 0b of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. Before
115/// this, export read the inline `bundle.skills` column
116/// while agent launch read the ref tables (`write_agent_config_files` in
117/// `app_api/agent_open.rs`), and nothing kept the two in step — so a skill
118/// bound in Memory → Skills ran at launch but exported as an empty `skills/`
119/// directory, with no warning. One
120/// resolver, used by every export path, is what stops that recurring.
121///
122/// **Scope note:** this is deliberately narrower than `effective_skills`,
123/// which additionally unions an *agent's* own binds, globals and the legacy
124/// blob. Those answer "what does this agent run with";
125/// this answers "what is in this bundle", which is what an ABF describes. The
126/// two are not meant to be equal, only to agree about the bundle's own
127/// contribution.
128///
129/// `managed_list` returns the whole catalog with a `bound_to_bundle` flag
130/// rather than just the bound rows, hence the filter; and because it joins
131/// through the catalog table, a ref whose catalog row has been deleted
132/// resolves to nothing rather than reporting itself. `skill_delete` purges
133/// refs (`managed_delete` in
134/// `storage/managed.rs`), so that state is not reachable through the app
135/// within one channel — but no FK enforces it: the ref tables' catalog FK was
136/// dropped in schema v34, once the catalog moved to `identity_store`.
137pub(crate) fn resolve_bundle_components(
138    mstore: &crate::backend::storage::store::Store,
139    identity_store: &crate::backend::storage::store::Store,
140    bundle_id: &str,
141) -> Result<ResolvedComponents, String> {
142    let skills: Vec<crate::backend::storage::Skill> = mstore
143        .bundle_skill_list(identity_store, bundle_id)
144        .map_err(|e| format!("failed to resolve bundle skills: {e}"))?
145        .into_iter()
146        .filter(|item| item.bound_to_bundle)
147        .map(|item| item.skill)
148        .collect();
149    Ok(ResolvedComponents { skills })
150}
151
152/// Warning the agent-less `bundle.export` pushes when the bundle it is
153/// exporting is bound to an agent that actually has native memory.
154///
155/// Mirror of [`crate::backend::bundle_import::MEMORY_COMPONENT_IGNORED_WARNING`]
156/// on the import side, and a constant for the same reason that one is: the push
157/// site and the test that pins the wording must not drift apart.
158///
159/// `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md` §3.1/§5.1. The
160/// asymmetry worth fixing was never that `bundle.export` omits memory — a
161/// bundle detached from any agent has no memory to carry, and omitting it is
162/// correct. It is that import *announces* the omission and export did not,
163/// while every `components` key is optional (`export_bundle` in
164/// `bundle_export.rs` inserts each only when non-empty), so a missing `memory`
165/// key is indistinguishable from "this bundle had none".
166pub(crate) const MEMORY_NOT_EXPORTED_WARNING: &str =
167    "memory: this bundle is bound to an agent with native memory, which bundle.export does not carry — use bundle.export_for_agent to include it";
168
169/// Does any agent bound to `bundle_id` have mirrored native memory?
170///
171/// **Both bindings count.** A bundle is reachable two ways, and they are
172/// different columns: `AgentDefinition.memory_id` is the agent's own dedicated
173/// bundle (stored in `db_agents.default_memory_id`), while
174/// `AgentInstance.memory_id` is one *launch* deliberately pointed at some other
175/// bundle (`AgentDefinition::memory_id` in `storage/agents/mod.rs`). Checking only
176/// the definition would miss exactly the case the operator is most likely to
177/// hit — exporting the bundle a running instance was launched with. Native memory is keyed on the
178/// definition id either way (`build_export_for_agent` resolves the agent with
179/// `agent_def_get` before reading memory), so the instance path resolves
180/// through `definition_id`.
181///
182/// Reads the `db_agent_native_memory` mirror and deliberately does NOT call
183/// `refresh_memory_mirror_from_live_fs` first, unlike `build_export_for_agent`
184/// (which must, because it is about to *copy* the files —
185/// `app_api/bundle/export_for_agent.rs`). Refreshing writes to the store, and
186/// an agent-less export has no business mutating per-agent state as a side
187/// effect of producing a warning.
188///
189/// **Known limitation — a cross-channel binding is invisible here, and cannot
190/// currently be made visible.** Both lookups resolve the binding from
191/// channel-local SQLite: `instance_list` reads this channel's rows, and
192/// `agent_def_list`'s global overlay only preserves `memory_id` when a local
193/// row exists (`agent_def_list` in `storage/agents/definition.rs`). An agent created in
194/// another channel has no local row, so it comes back with an empty
195/// `memory_id` — because `DefinitionRecordV1` does not carry the field at all
196/// (`record_to_agent_definition` in `storage/def_registry_mirror.rs`). The
197/// bundle and the memory mirror are global; only the binding between them is not. So exporting a shared
198/// bundle from a channel other than the one its agent was created in will not
199/// warn, which is exactly the portability case the warning is for (Codex, PR
200/// #3147).
201///
202/// Closing it means adding `memory_id` to the registry wire format, which is
203/// already a tracked gap for bigger reasons than this warning — the same
204/// missing field makes a cross-channel reopen start unbound and limits m0021's
205/// backfill to local SQLite. Tracked in #3148; deliberately not widened here,
206/// because warning whenever the binding is merely *unknown* would fire on
207/// unrelated bundles and train operators to ignore it.
208///
209/// The other cost is under-warning on a stale mirror: an agent whose memory
210/// exists on disk but was never mirrored is missed. That is the residual gap
211/// `SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md` already documents and
212/// accepts for the mirror generally. Both costs are one-directional and this
213/// warning is advisory — it changes no bytes in the archive.
214pub(super) fn bound_agent_has_native_memory(
215    id_store: &crate::backend::storage::store::Store,
216    mstore: &crate::backend::storage::store::Store,
217    bundle_id: &str,
218) -> bool {
219    // A blank bundle_id is the "blank singleton" sentinel that every unbound
220    // agent carries — treating it as a binding would warn on nearly every
221    // export.
222    if bundle_id.is_empty() {
223        return false;
224    }
225
226    let has_memory = |agent_id: &str| {
227        id_store
228            .agent_native_memory_list_meta(agent_id)
229            .map(|rows| !rows.is_empty())
230            .unwrap_or(false)
231    };
232
233    // Best-effort throughout: a store error here must not fail an export that
234    // is otherwise fine. Neither table is read by the export itself.
235    let bound_by_definition = mstore
236        .agent_def_list()
237        .map(|agents| {
238            agents
239                .iter()
240                .filter(|a| a.memory_id == bundle_id)
241                .any(|a| has_memory(&a.id))
242        })
243        .unwrap_or(false);
244    if bound_by_definition {
245        return true;
246    }
247
248    mstore
249        .instance_list(None, None)
250        .map(|instances| {
251            instances
252                .iter()
253                .filter(|i| i.memory_id == bundle_id)
254                .any(|i| has_memory(&i.definition_id))
255        })
256        .unwrap_or(false)
257}
258
259/// Register `entries` under `components.<key>` in the export's manifest (`bundle.json`).
260///
261/// Takes JSON values rather than paths: most components are a list of archive
262/// paths, but `projectInstructions` carries an object per file, because a bare
263/// path could not express the `owner` discriminator that keeps import from
264/// installing somebody else's instructions.
265///
266/// The manifest is built as an inline `json!` literal in `bundle_export.rs`
267/// with no struct behind it (`export_bundle`), so every agent-aware
268/// component has to reopen and rewrite it. Shared by the memory splice below
269/// and the history splice, which otherwise duplicated the find-parse-rewrite
270/// dance byte for byte.
271///
272/// No-ops on an empty list: `components` keys are all optional, and writing
273/// `"history": []` would assert "this archive has an empty history" where
274/// absence already says "none".
275pub(super) fn set_manifest_component(
276    export: &mut crate::backend::bundle_export::BundleExport,
277    key: &str,
278    entries: Vec<serde_json::Value>,
279) -> Result<(), String> {
280    if entries.is_empty() {
281        return Ok(());
282    }
283    let manifest_name = crate::backend::bundle_import::manifest_file_name(|n| export.files.iter().any(|f| f.path == n))
284        .ok_or_else(|| "bundle export has no manifest".to_string())?;
285    let manifest_idx = export.files.iter().position(|f| f.path == manifest_name).expect("found above");
286    let mut manifest: serde_json::Value = serde_json::from_str(&export.files[manifest_idx].content)
287        .map_err(|e| format!("{manifest_name}: {e}"))?;
288    manifest["components"][key] = json!(entries);
289    export.files[manifest_idx].content =
290        serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
291    Ok(())
292}
293
294/// Register the transcript files `bundle.export_for_agent_with_history` already
295/// wrote into the archive under `components.history`.
296///
297/// `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md` §3.5: the files were
298/// pushed into `export.files` (`app_api/bundle/export_for_agent.rs`) and
299/// counted in the RPC response, but the manifest was never touched after the
300/// memory splice — so a
301/// consumer reading `components.*` to learn what an archive holds could not see
302/// history at all. The paths are caller-supplied rather than re-derived here so
303/// this cannot drift from what was actually written.
304pub(super) fn splice_history_component(
305    export: &mut crate::backend::bundle_export::BundleExport,
306    history_paths: Vec<String>,
307) -> Result<(), String> {
308    set_manifest_component(export, "history", history_paths.into_iter().map(|p| json!(p)).collect())
309}
310
311/// Splice native-memory files into an already-built bundle export's
312/// `bundle.json` manifest and files list, adding `components.memory` (ABF
313/// v0.2 §2.3). Kept OUTSIDE `bundle_export.rs` deliberately — that
314/// module's `export_bundle()` is scoped to a bundle's own components
315/// (instructions/skills/MCP/accounts) with no concept of "agent" or
316/// native memory at all; memory is agent-scoped, not bundle-scoped, so
317/// splicing it in here (the RPC-handler layer, which already resolves
318/// other agent-scoped data like skill rows) keeps that module's
319/// documented scope intact rather than growing it a fifth, unrelated
320/// component category. A no-op when `memory_files` is empty — matches the
321/// existing omit-empty-components convention used elsewhere in the
322/// manifest.
323/// Carry the agent's project instructions into the export as
324/// `components.projectInstructions`.
325///
326/// Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. Files
327/// land under `instructions/project/<sanitized-path>`, and each manifest entry
328/// carries its original `path`, a `contentHash`, and an `owner`.
329///
330/// **`owner` is the load-bearing field.** It is what stops this from becoming a
331/// way to install one repository's instructions into another: an importer that
332/// treated a `foreign` entry as content to write would be doing precisely what
333/// `SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md` exists to prevent.
334/// Import surfaces these and never applies them (spec §5.2), so an operator who
335/// wants them acts deliberately.
336///
337/// Only files that exist and were readable are carried — there is nothing to
338/// put in an archive otherwise. Their absence from the manifest is therefore
339/// not a claim that the source agent had none; `agent.project_instructions` is
340/// where the fuller picture, including absent and unreadable files, lives.
341///
342/// "Readable" is decided by `error`/`truncated`, NOT by whether the content is
343/// empty (Codex P2, PR #3163). A real, readable, zero-byte `CLAUDE.md` is a
344/// fact about the source agent — dropping it would make the export
345/// indistinguishable from one where the file was absent, which is the exact
346/// kind of quiet lie this component exists to remove.
347///
348/// Paths go through the same sanitizer the exporter uses for context files, so
349/// a scanned path can never write outside the archive's own tree.
350pub(super) fn splice_project_instructions_component(
351    export: &mut crate::backend::bundle_export::BundleExport,
352    files: &[crate::backend::project_instructions::ProjectInstructionFile],
353) -> Result<(), String> {
354    use crate::backend::project_instructions::InstructionOwner;
355    use std::collections::HashSet;
356
357    let mut entries: Vec<serde_json::Value> = Vec::new();
358    // Case-INSENSITIVE, for the same reason the context-file loop in
359    // `bundle_export.rs` is (reagent P2, PR #2333): a case-sensitive source
360    // filesystem can hold `.github/instructions/A.md` and `.../a.md`, and the
361    // scan in `project_instructions.rs` will find both — but they collide on
362    // extraction on Windows/macOS, where one snapshot silently overwrites the
363    // other. Scanned directories make this reachable here in a way it isn't
364    // for the fixed registry paths (Codex P2, PR #3163). The written path
365    // keeps its original case; only the check is folded.
366    let mut used_paths: HashSet<String> = HashSet::new();
367    for file in files {
368        if !file.exists || file.error.is_some() || file.truncated {
369            continue;
370        }
371        let Some(safe) = crate::backend::bundle_export::sanitize_context_relative_path(&file.path)
372        else {
373            // Warn rather than dropping in silence — a backup tool that loses
374            // an input without saying so defeats its own purpose (reagent P1,
375            // PR #2333, on the context-file equivalent).
376            export.warnings.push(format!(
377                "projectInstructions: \"{}\" could not be sanitized into an \
378                 archive-relative path; skipped",
379                file.path
380            ));
381            continue;
382        };
383        let out_path = format!("instructions/project/{safe}");
384        if !used_paths.insert(out_path.to_lowercase()) {
385            export.warnings.push(format!(
386                "projectInstructions: \"{}\" normalizes to the same path as an \
387                 earlier entry ({out_path}); skipped to avoid overwriting it",
388                file.path
389            ));
390            continue;
391        }
392        export.files.push(crate::backend::bundle_export::BundleExportFile {
393            path: out_path.clone(),
394            content: file.content.clone(),
395        });
396        entries.push(json!({
397            "path": file.path,
398            "file": out_path,
399            "contentHash": file.content_hash,
400            "owner": match file.owner {
401                InstructionOwner::Agentmux => "agentmux",
402                InstructionOwner::Foreign => "foreign",
403            },
404        }));
405    }
406    set_manifest_component(export, "projectInstructions", entries)
407}
408
409pub(super) fn splice_memory_component(
410    export: &mut crate::backend::bundle_export::BundleExport,
411    memory_files: &[(String, String)],
412) -> Result<(), String> {
413    if memory_files.is_empty() {
414        return Ok(());
415    }
416    let mut manifest_memory: Vec<String> = Vec::new();
417    for (filename, content) in memory_files {
418        // Filenames here always came from db_agent_native_memory, which
419        // only ever accepts app-validated names (validate_filename in
420        // native_memory_handlers.rs: alphanumeric + "-_", ends ".md", no
421        // separators) — unlike instructions_by_provider's keys (§2.2),
422        // there's no untrusted-manifest path that could smuggle an unsafe
423        // segment in here, so no additional sanitization is needed.
424        let out_path = format!("memory/{filename}");
425        export.files.push(crate::backend::bundle_export::BundleExportFile {
426            path: out_path.clone(),
427            content: content.clone(),
428        });
429        manifest_memory.push(out_path);
430    }
431    set_manifest_component(export, "memory", manifest_memory.into_iter().map(|p| json!(p)).collect())
432}