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}