Skip to main content

agentmux_srv/server/app_api/bundle/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `bundle.*` MuxAPI handlers — the Armory Bundle Format (ABF) surface.
5//!
6//! Module layout (split 2026-09-22): this file holds registration, the plain
7//! CRUD/validate/self_get handlers, and the helpers every family shares;
8//! each `bundle.*` family lives in its own child module. The children are
9//! glob-imported back here so that, exactly as when this was one file, any
10//! function can call any other without an import.
11
12use super::*;
13
14/// Raw `[{path, content}]` entry shape, shared by `bundle.import`'s `files`
15/// input and the Phase 3 `bundle.import.preview`/`.commit` handlers. Now one
16/// definition in rpc_types rather than a private copy here, so the generated
17/// bindings and this file cannot disagree about it.
18use crate::backend::rpc_types::BundleImportFileEntry as FileEntry;
19
20// `PreviewReq`/`CommitReq`/`SkillSelection` moved to
21// backend::rpc_types::bundle_import so the bindings generator can see them --
22// they were private to this file, which is why the frontend's copies were
23// hand-written and, in preview's case, wrong (it declared only `file_path`).
24use crate::backend::rpc_types::{
25    BundleImportSkillSelection as SkillSelection,
26    CommandBundleImportCommitData as CommitReq,
27    CommandBundleImportPreviewData as PreviewReq,
28};
29
30mod components;
31mod export;
32mod export_for_agent;
33mod import;
34mod import_for_agent;
35
36#[cfg(test)]
37mod tests;
38
39use components::*;
40use export::*;
41use export_for_agent::*;
42use import::*;
43use import_for_agent::*;
44
45// Referenced from outside `bundle` under these paths and visibilities.
46pub(crate) use components::purge_bundle_component_refs;
47pub(super) use components::resolve_bundle_components;
48pub(crate) use components::MEMORY_NOT_EXPORTED_WARNING;
49
50/// Per-agent async lock serializing `bundle.import_for_agent`'s "check
51/// zero existing memory rows, then write" sequence — ABF v0.2 §2.3,
52/// reagent P2 on PR #2527. Mirrors `agent_open.rs`'s `AGENT_OPEN_LOCKS`
53/// precedent exactly, including its scope note: this only serializes
54/// calls handled by THIS process, not a genuinely different AgentMux
55/// instance/channel racing the same agent_id.
56static BUNDLE_IMPORT_FOR_AGENT_LOCKS: std::sync::LazyLock<
57    std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
58> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
59
60fn bundle_import_for_agent_lock(agent_id: &str) -> Arc<tokio::sync::Mutex<()>> {
61    let mut locks = BUNDLE_IMPORT_FOR_AGENT_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
62    locks
63        .entry(agent_id.to_string())
64        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
65        .clone()
66}
67
68pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
69    register_bundle_list(engine, state);
70    register_bundle_get(engine, state);
71    register_bundle_upsert(engine, state);
72    register_bundle_delete(engine, state);
73    register_bundle_self_get(engine, state);
74    register_bundle_export(engine, state);
75    register_bundle_import(engine, state);
76    register_bundle_import_preview(engine, state);
77    register_bundle_import_commit(engine, state);
78    register_bundle_export_for_agent(engine, state);
79    register_bundle_export_for_agent_with_history(engine, state);
80    register_bundle_import_for_agent(engine, state);
81    register_bundle_validate(engine, state);
82    register_agent_project_instructions(engine, state);
83}
84
85/// Normalize a `bundle.upsert` request body into the shape the `Bundle` struct
86/// deserializes from, so MuxAPI accepts the request exactly as documented
87/// in the spec:
88///   - `id` may be omitted to create (the struct has no serde default), so an
89///     absent or null `id` is filled with an empty string (the handler then
90///     mints a UUID).
91///   - `context_files` / `mcp_servers` / `skills` are JSON-encoded array strings
92///     on the struct, but the spec shows them as JSON arrays. Array values are
93///     re-encoded to their JSON string form; values already given as strings
94///     pass through untouched.
95pub(super) fn normalize_bundle_upsert_input(mut data: serde_json::Value) -> serde_json::Value {
96    if let serde_json::Value::Object(ref mut map) = data {
97        match map.get("id") {
98            Some(serde_json::Value::String(_)) => {}
99            _ => {
100                map.insert("id".to_string(), serde_json::Value::String(String::new()));
101            }
102        }
103        for key in ["context_files", "mcp_servers", "skills"] {
104            if let Some(v) = map.get(key) {
105                if v.is_array() {
106                    let encoded =
107                        serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string());
108                    map.insert(key.to_string(), serde_json::Value::String(encoded));
109                }
110            }
111        }
112    }
113    data
114}
115
116// The deprecated `preset.*` aliases (Phase 2's one-release compat window)
117// were retired as part of the ABF UI-alignment pass —
118// `bundle.*` is the only surface now; see the ABF branding sweep this
119// change shipped alongside.
120
121/// One `bundle.list` row: a bundle's summary fields, not the whole bundle.
122#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
123#[ts(export, export_to = "../../../frontend/types/rpc/")]
124pub(crate) struct BundleListItem {
125    pub(crate) id: String,
126    pub(crate) name: String,
127    pub(crate) description: String,
128    pub(crate) provider: String,
129    pub(crate) model: String,
130    pub(crate) is_blank: bool,
131    #[ts(type = "number")]
132    pub(crate) updated_at: i64,
133}
134
135/// Response for `bundle.list`: the same rows under both keys (see
136/// `bundle_list_impl`).
137#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
138#[ts(export, export_to = "../../../frontend/types/rpc/")]
139pub(crate) struct BundleListResponse {
140    pub(crate) bundles: Vec<BundleListItem>,
141    pub(crate) presets: Vec<BundleListItem>,
142}
143
144fn register_bundle_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
145    let state = state.clone();
146    engine.register_typed(COMMAND_BUNDLE_LIST, move |_req: Option<NoArgsReq>, _ctx| {
147        let state = state.clone();
148        async move { bundle_list_impl(&state).await }
149    });
150}
151
152#[derive(serde::Deserialize, Default, ts_rs::TS)]
153#[ts(export, export_to = "../../../frontend/types/rpc/")]
154pub(super) struct BundleGetReq {
155    #[serde(default)]
156    #[ts(as = "Option<String>", optional)]
157    id: String,
158    #[serde(default)]
159    #[ts(as = "Option<String>", optional)]
160    name: String,
161}
162
163fn register_bundle_get(engine: &Arc<WshRpcEngine>, state: &AppState) {
164    let state = state.clone();
165    engine.register_typed(COMMAND_BUNDLE_GET, move |req: BundleGetReq, _ctx| {
166        let state = state.clone();
167        async move { bundle_get_impl(&state, &req.id, &req.name).await }
168    });
169}
170
171// NOT migrated to `register_typed`, deliberately, unlike the rest of the
172// bundle surface. `bundle_validate_impl` runs its payload through
173// `normalize_bundle_upsert_input` BEFORE deserializing it -- that is what
174// fills a missing `id` with "" so an unsaved draft can be validated, and what
175// coerces array-valued `context_files`/`mcp_servers`/`skills` into the
176// JSON-encoded strings `Bundle` expects.
177//
178// `register_typed` deserializes the payload into `Req` first and hands the
179// handler a typed value, so there is no point at which that normalize step
180// could run. Typing this command would mean re-implementing normalize as
181// custom serde deserializers -- duplicating security-relevant coercion logic
182// to satisfy the generator, which is the wrong trade. The RESPONSE is typed
183// (`ValidationReport`, ts-rs-generated) and that is where the drift risk
184// actually was; the request stays a `Value` on purpose.
185fn register_bundle_validate(engine: &Arc<WshRpcEngine>, _state: &AppState) {
186    // `Value` request: bundle_validate_impl normalizes the loose bundle input
187    // itself, as bundle.upsert does.
188    engine.register_typed(COMMAND_BUNDLE_VALIDATE, |data: serde_json::Value, _ctx| async move {
189        bundle_validate_impl(data)
190    });
191}
192
193fn register_bundle_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
194    let make = |state: &AppState| {
195        let id_store = state.id_store.clone();
196        let broker = state.broker.clone();
197        // `Value` request, as bundle.validate's: the payload goes through
198        // `normalize_bundle_upsert_input` before it is a `Bundle`.
199        move |data: serde_json::Value, _ctx: RpcContext| {
200            let id_store = id_store.clone();
201            let broker = broker.clone();
202            async move {
203                let mut memory: Bundle =
204                    serde_json::from_value(normalize_bundle_upsert_input(data))
205                        .map_err(|e| format!("bundle.upsert: {e}"))?;
206
207                // S4: guard on the target id, not the caller-supplied is_blank flag
208                // (which defaults false and can be omitted to bypass is_blank check).
209                if memory.id == "blank" || memory.id.starts_with("seed-") || memory.is_blank {
210                    return Err("FORBIDDEN: cannot mutate a protected bundle".to_string());
211                }
212                // Guard existing global bundles: an agent must not be able to demote or
213                // corrupt a shared global bundle it doesn't own by supplying its id.
214                if !memory.id.is_empty() {
215                    if let Some(existing) = id_store.bundle_get(&memory.id)
216                        .map_err(|e| format!("bundle.upsert: {e}"))?
217                    {
218                        if existing.is_global {
219                            return Err("FORBIDDEN: cannot mutate a global bundle".to_string());
220                        }
221                        // provider/model are a "suggested for" hint and may
222                        // change: the agent owns its harness, and its lock
223                        // moved there (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1).
224                    }
225                }
226                if memory.id.is_empty() {
227                    memory.id = uuid::Uuid::new_v4().to_string();
228                }
229                // S4a: strip caller-supplied escalation fields.
230                memory.is_global = false;
231                memory.sort_order = 0;
232
233                let now = agentmux_common::time::now_ms();
234                if memory.created_at == 0 { memory.created_at = now; }
235                memory.updated_at = now;
236
237                id_store.bundle_upsert(&memory)
238                    .map_err(|e| format!("bundle.upsert: {e}"))?;
239                broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
240                Ok(memory)
241            }
242        }
243    };
244    engine.register_typed(COMMAND_BUNDLE_UPSERT, make(state));
245}
246
247fn register_bundle_delete(engine: &Arc<WshRpcEngine>, state: &AppState) {
248    let make = |state: &AppState| {
249        let id_store = state.id_store.clone();
250        let mstore = state.mstore.clone();
251        let broker = state.broker.clone();
252        // `{ id }`, the same request `deletememory` takes.
253        move |req: CommandDeleteBundleData, _ctx: RpcContext| {
254            let id_store = id_store.clone();
255            let mstore = mstore.clone();
256            let broker = broker.clone();
257            async move {
258                if req.id == "blank" {
259                    return Err("FORBIDDEN: cannot delete a seeded bundle".to_string());
260                }
261
262                match id_store.bundle_delete(&req.id) {
263                    Ok(deleted) => {
264                        if deleted {
265                            purge_bundle_component_refs(&mstore, &req.id);
266                            broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
267                        }
268                        Ok(DeleteBundleResult { deleted })
269                    }
270                    Err(crate::backend::storage::error::StoreError::Other(msg))
271                        if msg.contains("seed") || msg.contains("seeded") =>
272                    {
273                        Err(format!("FORBIDDEN: cannot delete a seeded bundle"))
274                    }
275                    Err(e) => Err(format!("bundle.delete: {e}")),
276                }
277            }
278        }
279    };
280    engine.register_typed(COMMAND_BUNDLE_DELETE, make(state));
281}
282
283#[derive(serde::Deserialize, ts_rs::TS)]
284#[ts(export, export_to = "../../../frontend/types/rpc/")]
285pub(super) struct BundleSelfGetReq { agent_id: String }
286
287fn register_bundle_self_get(engine: &Arc<WshRpcEngine>, state: &AppState) {
288    let state = state.clone();
289    engine.register_typed(COMMAND_BUNDLE_SELF_GET, move |req: BundleSelfGetReq, ctx| {
290        let state = state.clone();
291        async move {
292            check_s1(&state.mstore, &ctx, &req.agent_id)?;
293            bundle_self_get_impl(&state, &req.agent_id).await
294        }
295    });
296}
297
298/// Resolve the agent definition behind an S1-authenticated `agent_id`.
299///
300/// **`check_s1` authenticates a SLUG, not a UUID.** `RpcContext.agent_id` is
301/// "slug of the authenticated agent from bus:register"
302/// (its doc in `rpc_types/misc.rs`), while `agent_def_get` queries `db_agents`
303/// by its UUID primary key — so calling it with the authenticated value
304/// returns `None` for every real caller (#3156).
305///
306/// Delegates to `resolve_agent_definition_id`, the existing resolver for
307/// exactly this, rather than hand-rolling the lookup: it already carries the
308/// registry-only fallback a previous review round forced in, for a live agent
309/// that never created a local `db_agents` row. A fresh two-tier lookup here
310/// would have that hole again.
311fn resolve_agent_for_s1(
312    state: &AppState,
313    agent_id: &str,
314) -> Result<crate::backend::storage::AgentDefinition, String> {
315    let def_id = resolve_agent_definition_id(state, agent_id)?;
316    state
317        .mstore
318        .agent_def_get(&def_id)
319        .map_err(|e| e.to_string())?
320        .ok_or_else(|| format!("no agent with slug or id {agent_id}"))
321}
322
323#[derive(serde::Deserialize, ts_rs::TS)]
324#[ts(export, export_to = "../../../frontend/types/rpc/")]
325pub(super) struct AgentProjectInstructionsReq {
326    agent_id: String,
327}
328
329/// One file in `agent.project_instructions`: a resolved
330/// `ProjectInstructionFile`, plus how it compares to the last launch's
331/// observation of it.
332#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
333#[ts(export, export_to = "../../../frontend/types/rpc/")]
334pub(super) struct AgentProjectInstructionFile {
335    path: String,
336    exists: bool,
337    #[ts(type = "number")]
338    size_bytes: u64,
339    content_hash: String,
340    /// `"agentmux"` or `"foreign"`.
341    owner: String,
342    content: String,
343    truncated: bool,
344    error: Option<String>,
345    change: crate::backend::storage::project_instructions::InstructionChange,
346    /// Null when the file was never observed.
347    #[ts(type = "number | null")]
348    last_observed_at: Option<i64>,
349}
350
351/// Response for `agent.project_instructions`.
352#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
353#[ts(export, export_to = "../../../frontend/types/rpc/")]
354pub(super) struct AgentProjectInstructionsResponse {
355    provider: String,
356    working_directory: String,
357    files: Vec<AgentProjectInstructionFile>,
358}
359
360/// `agent.project_instructions` — what this agent will actually read.
361///
362/// Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. Until
363/// now nothing could answer this: AgentMux knew what it wrote and recorded a
364/// single boolean about anything it found already there (§2.4).
365///
366/// Agent-scoped, so `check_s1` applies — an agent's working directory contents
367/// are not window-scoped data the way a bundle is.
368///
369/// **Read-only, and there is no write counterpart on purpose.** A foreign
370/// instruction file belongs to the repository;
371/// `SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md` exists to keep AgentMux
372/// out of it, and this must not become a way around that.
373fn register_agent_project_instructions(engine: &Arc<WshRpcEngine>, state: &AppState) {
374    let make = |state: AppState| {
375        move |req: AgentProjectInstructionsReq, ctx: RpcContext| {
376            let state = state.clone();
377            async move {
378                check_s1(&state.mstore, &ctx, &req.agent_id)?;
379
380                let agent = resolve_agent_for_s1(&state, &req.agent_id)
381                    .map_err(|e| format!("agent.project_instructions: {e}"))?;
382
383                // The directory the agent will actually run in: a blank
384                // working_directory means the per-agent default and a `~`
385                // path is expanded at launch, so the stored value alone
386                // describes a directory the agent never uses (Codex, #3156).
387                let work_dir = crate::backend::project_instructions::effective_working_dir(
388                    &agent.working_directory,
389                    &agent.name,
390                );
391                let files = crate::backend::project_instructions::resolve_project_instructions(
392                    &agent.provider,
393                    &work_dir,
394                );
395
396                // Compare against what was recorded at the last launch, so a
397                // repository file that changed underneath is visible rather
398                // than merely present. Observations are written at
399                // `agent.open` (`observe_project_instructions`) — this RPC
400                // stays a pure read, which is why a file edited since the last
401                // launch reports `modified` every time it is called until the
402                // agent is next opened.
403                let previous = state
404                    .mstore
405                    .project_instructions_list(&agent.id)
406                    .unwrap_or_default();
407                let mut enriched: Vec<AgentProjectInstructionFile> = files
408                    .iter()
409                    .map(|f| {
410                        let prev = previous.iter().find(|p| p.path == f.path);
411                        let change = crate::backend::storage::project_instructions::classify_change(
412                            prev,
413                            f.exists,
414                            &f.content_hash,
415                        );
416                        AgentProjectInstructionFile {
417                            path: f.path.clone(),
418                            exists: f.exists,
419                            size_bytes: f.size_bytes,
420                            content_hash: f.content_hash.clone(),
421                            owner: match f.owner {
422                                crate::backend::project_instructions::InstructionOwner::Agentmux => "agentmux",
423                                crate::backend::project_instructions::InstructionOwner::Foreign => "foreign",
424                            }
425                            .to_string(),
426                            content: f.content.clone(),
427                            truncated: f.truncated,
428                            error: f.error.clone(),
429                            change,
430                            last_observed_at: prev.map(|p| p.observed_at),
431                        }
432                    })
433                    .collect();
434
435                // A path the resolver no longer returns at all — a scanned
436                // `.github/instructions/*.instructions.md` deleted since the
437                // last launch — would otherwise just vanish from the response,
438                // never reported as `removed`, and recreating it later would
439                // read as `unchanged` against the stale row (Codex, PR #3162).
440                // The declared paths always come back (absent ones included),
441                // so this only ever fires for dynamically scanned files.
442                for prev in previous.iter().filter(|p| !files.iter().any(|f| f.path == p.path)) {
443                    let change = crate::backend::storage::project_instructions::classify_change(
444                        Some(prev),
445                        false,
446                        "",
447                    );
448                    enriched.push(AgentProjectInstructionFile {
449                        path: prev.path.clone(),
450                        exists: false,
451                        size_bytes: 0,
452                        content_hash: String::new(),
453                        owner: prev.owner.clone(),
454                        content: String::new(),
455                        truncated: false,
456                        error: None,
457                        change,
458                        last_observed_at: Some(prev.observed_at),
459                    });
460                }
461
462                Ok(AgentProjectInstructionsResponse {
463                    provider: agent.provider,
464                    working_directory: work_dir,
465                    files: enriched,
466                })
467            }
468        }
469    };
470    engine.register_typed(COMMAND_AGENT_PROJECT_INSTRUCTIONS, make(state.clone()));
471}
472
473/// Read-only account-requirement resolution (spec §4.5) — one identity
474/// lookup per DISTINCT provider, not per requirement row (codex P1, PR
475/// #2379 round 4: `bundle_import.rs` bounds the requirements array length,
476/// but even at that bound many rows commonly share a handful of
477/// providers). Extracted here (Phase 3 spec §3.1's account-resolution
478/// implementation note) so the existing `bundle.import` route and the new
479/// `bundle.import.preview`/`.commit` handlers share the exact same
480/// resolution logic rather than duplicating it in a second place where it
481/// could drift.
482/// Full, untruncated values — callers apply the shared bounded-display
483/// projection ([`bounded_display`]) to `id`/`provider`/`env` before ever
484/// serializing these into an RPC response (Phase 3 spec §3.1, round 11).
485struct RequirementResolution {
486    id: String,
487    provider: String,
488    env: String,
489    match_count: usize,
490}
491
492impl RequirementResolution {
493    fn resolved(&self) -> bool {
494        self.match_count == 1
495    }
496}
497
498fn resolve_account_requirements(
499    id_store: &crate::backend::storage::store::Store,
500    requirements: &[crate::backend::bundle_import::AccountRequirement],
501) -> Result<Vec<RequirementResolution>, String> {
502    let mut results: Vec<RequirementResolution> = Vec::new();
503    let mut match_count_by_provider: std::collections::HashMap<&str, usize> =
504        std::collections::HashMap::new();
505    for requirement in requirements {
506        let match_count = match match_count_by_provider.get(requirement.provider.as_str()) {
507            Some(&n) => n,
508            None => {
509                // Codex P2, PR #2379 round 2: account CRUD routes through
510                // id_store (shared/store.db when available), not mstore
511                // (the per-channel database).
512                let n = id_store
513                    .identity_list(Some(&requirement.provider))
514                    .map_err(|e| format!("account lookup failed: {e}"))?
515                    .len();
516                match_count_by_provider.insert(requirement.provider.as_str(), n);
517                n
518            }
519        };
520        results.push(RequirementResolution {
521            id: requirement.id.clone(),
522            provider: requirement.provider.clone(),
523            env: requirement.env.clone(),
524            match_count,
525        });
526    }
527    Ok(results)
528}
529
530/// Shared bounded-display projection (Phase 3 spec, round 12's governing
531/// rule) for every "meant to be short" field both `preview` and `commit`
532/// echo back: skill slug/description, requirement `id`/`provider`/`env`,
533/// context-file `display_path`, bundle `description`, and commit's
534/// `skipped_skills`/`resolved_requirement_ids` entries. `bundle.name` is
535/// NOT projected through this — it's bounded at parse time instead
536/// (round 13), since it's re-submitted verbatim as `bundle_name`.
537fn bounded_display(s: &str) -> String {
538    crate::backend::bundle_import::truncate_display(s, crate::backend::bundle_import::MAX_DISPLAY_FIELD_CHARS)
539}
540
541pub(crate) async fn bundle_list_impl(state: &AppState) -> Result<BundleListResponse, String> {
542    let memories = state.id_store.bundle_list().map_err(|e| format!("bundle.list: {e}"))?;
543    let bundles: Vec<BundleListItem> = memories.into_iter().map(|m| BundleListItem {
544        id: m.id, name: m.name, description: m.description,
545        provider: m.provider, model: m.model, is_blank: m.is_blank, updated_at: m.updated_at,
546    }).collect();
547    // Emit both keys: `bundle.list` callers read `bundles`; the separate
548    // REST route `/api/v1/agent/preset/list` (`PresetList` MCP tool,
549    // server/mod.rs) still reads `presets` and is unrelated to the internal
550    // WS `preset.*` aliases retired in this pass — do not drop `presets`
551    // here without first retiring that REST route too.
552    Ok(BundleListResponse { presets: bundles.clone(), bundles })
553}
554
555pub(crate) async fn bundle_get_impl(
556    state: &AppState,
557    id: &str,
558    name: &str,
559) -> Result<Bundle, String> {
560    let memory = if !id.is_empty() {
561        state.id_store.bundle_get(id).map_err(|e| format!("bundle.get: {e}"))?
562            .ok_or_else(|| format!("bundle.get: not found id={id}"))?
563    } else if !name.is_empty() {
564        let all = state.id_store.bundle_list().map_err(|e| format!("bundle.get: {e}"))?;
565        all.into_iter().filter(|m| m.name == name).max_by_key(|m| m.updated_at)
566            .ok_or_else(|| format!("bundle.get: not found name={name}"))?
567    } else {
568        return Err("bundle.get: provide id or name".to_string());
569    };
570    Ok(memory)
571}
572
573/// Structurally validate a bundle draft — ABF
574/// UI-alignment pass. Takes the SAME payload shape `bundle.upsert` accepts
575/// (reuses its `normalize_bundle_upsert_input`), not just an id, so the
576/// Memory → Bundles editor's "Validate" button can check an unsaved draft (including a
577/// brand-new bundle with no id yet) rather than only whatever was last
578/// persisted.
579///
580/// Store-free: what it checks is all in the draft. (It read the bundle's
581/// bound MCP servers while bundles carried them;
582/// `SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1.)
583pub(crate) fn bundle_validate_impl(
584    data: serde_json::Value,
585) -> Result<crate::backend::bundle_validate::ValidationReport, String> {
586    let memory: Bundle = serde_json::from_value(bundle::normalize_bundle_upsert_input(data))
587        .map_err(|e| format!("bundle.validate: {e}"))?;
588    Ok(crate::backend::bundle_validate::validate_bundle(&memory))
589}
590
591pub(crate) async fn bundle_self_get_impl<'o>(
592    state: &AppState,
593    owner: impl Into<SelfOwner<'o>>,
594) -> Result<Bundle, String> {
595    let agent_id = match owner.into() {
596        SelfOwner::Slug(slug) => slug,
597        // Identity M4c-2c: an attributed caller's own row, and its registry
598        // record only by that row's slug AND id — never a same-named
599        // agent's.
600        SelfOwner::Uid(uid) => {
601            let row = SelfOwner::caller_row(uid, &state.mstore)
602                .map_err(|e| format!("bundle.self.get: {e}"))?;
603            // The launch's bundle (`AgentInstance.memory_id`), as on the slug
604            // path — not `AgentDefinition.memory_id`, the default a launch
605            // inherits (`db_agents.default_memory_id`).
606            let launch = state.mstore.instance_get(uid)
607                .map_err(|e| format!("bundle.self.get: {e}"))?;
608            let memory_id = launch
609                .map(|i| i.memory_id)
610                .filter(|m| !m.is_empty())
611                .or_else(|| {
612                    crate::backend::agent_registry_lookup::find_active_record_by_slug_and_definition(
613                        &row.slug, &row.id,
614                    )
615                    .and_then(|rec| rec.data.memory_id)
616                });
617            return bundle_self_get_by_memory_id(state, memory_id);
618        }
619    };
620    let instance = state.mstore.instance_get_by_slug(agent_id)
621        .map_err(|e| format!("bundle.self.get: {e}"))?;
622    // `instance_get_by_slug` only ever hits the local `db_agents` table — a
623    // live agent that only exists in the global named-agent registry (never
624    // created a `db_agents` instance row) falls through with `instance:
625    // None` here. Without this fallback that silently read as "no bundle
626    // bound" and returned the blank/vanilla preset regardless of what's
627    // actually bound, with nothing to distinguish it from a genuinely
628    // unbound agent. Mirrors the two-tier lookup
629    // `native_memory_handlers::memory_dir_for_agent` already does for the
630    // same reason (see that function's own doc comment, issue #1836).
631    let memory_id = instance.as_ref()
632        .and_then(|i| if i.memory_id.is_empty() { None } else { Some(i.memory_id.clone()) })
633        .or_else(|| {
634            crate::server::native_memory_handlers::find_active_registry_record_by_slug(agent_id)
635                .and_then(|rec| rec.data.memory_id)
636        });
637    bundle_self_get_by_memory_id(state, memory_id)
638}
639
640/// The bundle `memory_id` names, or the blank singleton when none is bound.
641fn bundle_self_get_by_memory_id(
642    state: &AppState,
643    memory_id: Option<String>,
644) -> Result<Bundle, String> {
645    let memory = if let Some(mid) = memory_id {
646        state.id_store.bundle_get(&mid).map_err(|e| format!("bundle.self.get: {e}"))?
647            .ok_or_else(|| format!("bundle.self.get: memory_id {mid} not found"))?
648    } else {
649        // No bundle bound: return the blank singleton (two-step — list to find
650        // the blank id, then fetch the full object).
651        let all = state.id_store.bundle_list().map_err(|e| format!("bundle.self.get: {e}"))?;
652        let blank_id = all.into_iter().find(|m| m.is_blank).map(|m| m.id)
653            .ok_or_else(|| "bundle.self.get: blank singleton not found".to_string())?;
654        state.id_store.bundle_get(&blank_id).map_err(|e| format!("bundle.self.get: {e}"))?
655            .ok_or_else(|| "bundle.self.get: blank singleton row missing".to_string())?
656    };
657    Ok(memory)
658}
659
660/// The agent a self-scoped MuxAPI call is about — whose personal memory
661/// (`memory.*`, M4c-2b), linked accounts (`identity.self.accounts`,
662/// `identity.account.validate`), bound preset (`preset.get` self) and
663/// history (`history.search`, M4c-2c) — identity spec §6.5.9.
664#[derive(Debug, Clone, Copy)]
665pub(crate) enum SelfOwner<'a> {
666    /// The calling agent's own row, by its token's UID. Versions are keyed by
667    /// it as they are; the files are found by id
668    /// (`memory_dir_for_agent_by_id`), and a UID with no row or no directory
669    /// is an error — never another agent's directory (the #2901 class). A
670    /// directory that resolves but does not exist still lists as empty, as
671    /// on the slug path.
672    Uid(&'a str),
673    /// A slug, resolved as before M4c-2 (`memory_dir_for_agent`,
674    /// `resolve_agent_id`, the registry by slug): an Unattributed HTTP
675    /// caller, and the WS RPC.
676    Slug(&'a str),
677}
678
679impl<'a> From<&'a str> for SelfOwner<'a> {
680    fn from(slug: &'a str) -> Self {
681        Self::Slug(slug)
682    }
683}
684
685impl<'a> From<&'a String> for SelfOwner<'a> {
686    fn from(slug: &'a String) -> Self {
687        Self::Slug(slug)
688    }
689}
690
691impl<'a> SelfOwner<'a> {
692    /// The owner of an HTTP request: the caller's own row when attributed,
693    /// whatever `agent_id` names — a name that is not the caller's is counted
694    /// by M4a-2 — so a name two agents share no longer selects the other's
695    /// memory, accounts or history. An Unattributed request keeps the slug, counted as
696    /// `by_name_counter`.
697    pub(crate) fn of(
698        caller: Option<&'a crate::server::caller::Caller>,
699        slug: &'a str,
700        by_name_counter: &'static str,
701    ) -> Self {
702        match caller.and_then(crate::server::caller::Caller::uid) {
703            Some(uid) => Self::Uid(uid),
704            None => {
705                crate::backend::agent_resolve::record_uid_fallback(by_name_counter);
706                Self::Slug(slug)
707            }
708        }
709    }
710
711    /// The UID or the slug, for messages and logs.
712    pub(super) fn label(self) -> &'a str {
713        match self {
714            Self::Uid(v) | Self::Slug(v) => v,
715        }
716    }
717
718    /// The calling agent's row — a UID whose row is gone (a token that
719    /// outlived its agent) is an error on every path, never an empty history
720    /// or listing (ReAgent P1 on #3602).
721    pub(crate) fn caller_row(
722        uid: &str,
723        mstore: &crate::backend::storage::store::Store,
724    ) -> Result<crate::backend::storage::AgentDefinition, String> {
725        mstore
726            .agent_def_get(uid)
727            .map_err(|e| format!("store: {e}"))?
728            .ok_or_else(|| format!("calling agent {uid} not found"))
729    }
730
731    /// The owner's live memory directory.
732    pub(super) fn dir(self, mstore: &crate::backend::storage::store::Store) -> Result<std::path::PathBuf, String> {
733        match self {
734            Self::Uid(uid) => {
735                let agent = Self::caller_row(uid, mstore).map_err(|e| format!("memory: {e}"))?;
736                crate::server::native_memory_handlers::memory_dir_for_agent_by_id(mstore, &agent)
737                    .ok_or_else(|| format!("memory: memory directory for agent {uid} not found"))
738            }
739            Self::Slug(slug) => crate::server::native_memory_handlers::memory_dir_for_agent(mstore, slug),
740        }
741    }
742
743    /// The owner's memory directory for a write: never an unverified guess
744    /// (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2).
745    pub(super) fn dir_for_write(self, mstore: &crate::backend::storage::store::Store) -> Result<std::path::PathBuf, String> {
746        let id = self.owner_id(mstore).map_err(|e| format!("memory: {e}"))?;
747        match mstore.agent_def_get(&id).map_err(|e| format!("memory: store: {e}"))? {
748            Some(agent) => crate::server::native_memory_handlers::memory_dir_for_write_by_id(mstore, &agent)
749                .map_err(|e| format!("memory: {e}")),
750            // No local row (a slug caller naming a registry-only agent): its
751            // own spawn record still proves its directory.
752            None => crate::server::native_memory_handlers::memory_dir_from_spawn(&id).ok_or_else(|| {
753                format!(
754                    "memory: agent {} has no local row and no spawn on record, so its memory directory \
755                     can't be verified for a write",
756                    self.label()
757                )
758            }),
759        }
760    }
761
762    /// The owner's definition id (`db_agents.id`) — what its memory versions,
763    /// mirror rows and identity links are keyed by.
764    pub(super) fn owner_id(self, mstore: &crate::backend::storage::store::Store) -> Result<String, String> {
765        match self {
766            Self::Uid(uid) => Self::caller_row(uid, mstore).map(|row| row.id),
767            Self::Slug(slug) => crate::server::native_memory_handlers::resolve_agent_uuid(mstore, slug),
768        }
769    }
770}