Skip to main content

agentmux_srv/server/service/
memory_adopt.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `memoryadopt` service — the paired CEF host calls this, host→srv, once a
5//! human has confirmed in the host's window which of an agent's earlier
6//! accounts' memory folders to adopt
7//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.4, phase M3c).
8//!
9//! **Host-only**, like `credential`: every method requires `args[1]` to be
10//! this srv instance's `AGENTMUX_HOST_REG_SECRET`. The shared `X-AuthKey` is
11//! in every agent's environment, so an RPC an agent can reach can't stand
12//! for a human's confirmation (see `credential.rs`'s module doc for the
13//! incident that established this). The choices themselves are only
14//! `(list_id, index, dir_hash)` from a list the server issued
15//! (`agent:memory:adoption_list`) — never a path.
16
17use serde::Deserialize;
18
19use crate::backend::memory_adopt::{self, AdoptError};
20use crate::backend::service::{get_arg, WebCallType, WebReturnType};
21
22use super::super::AppState;
23use super::credential::host_caller_allowed;
24
25pub(super) async fn handle_memory_adopt_service(state: &AppState, call: &WebCallType) -> WebReturnType {
26    let supplied = call.args.get(1).and_then(|v| v.as_str()).unwrap_or("");
27    if !host_caller_allowed(state.host_reg_secret.as_deref(), supplied) {
28        tracing::error!(
29            "[memoryadopt] REJECTED a {} call — args[1] is not this srv instance's host-registration \
30             secret. Only the paired host, after a human confirmed in its window, may adopt memory.",
31            call.method
32        );
33        return WebReturnType::error("memoryadopt: host-only");
34    }
35    match call.method.as_str() {
36        "Adopt" => handle_adopt(state, call).await,
37        "Release" => handle_release(call).await,
38        other => WebReturnType::error(format!("memoryadopt: unknown method {other}")),
39    }
40}
41
42#[derive(Deserialize)]
43struct Choice {
44    index: usize,
45    dir_hash: String,
46}
47
48#[derive(Deserialize)]
49struct AdoptArgs {
50    agent_id: String,
51    list_id: String,
52    choices: Vec<Choice>,
53}
54
55async fn handle_adopt(state: &AppState, call: &WebCallType) -> WebReturnType {
56    let args: AdoptArgs = match get_arg(&call.args, 0) {
57        Ok(a) => a,
58        Err(e) => return WebReturnType::error(format!("memoryadopt.Adopt: {e}")),
59    };
60    let Some(fs) = crate::backend::agent_session::global_transcript_store() else {
61        return WebReturnType::error("memoryadopt.Adopt: memory record store unavailable");
62    };
63    let mstore = state.mstore.clone();
64    let choices: Vec<(usize, String)> = args.choices.into_iter().map(|c| (c.index, c.dir_hash)).collect();
65    let result = tokio::task::spawn_blocking(move || {
66        memory_adopt::adopt(fs, &mstore, &args.agent_id, &args.list_id, &choices)
67    })
68    .await;
69    match result {
70        Ok(Ok(report)) => match serde_json::to_value(&report) {
71            Ok(v) => WebReturnType::success(v),
72            Err(e) => WebReturnType::error(format!("memoryadopt.Adopt: {e}")),
73        },
74        // A stable code the host can show: "changed" means list again.
75        Ok(Err(AdoptError::Changed { index })) => {
76            WebReturnType::error(format!("changed: folder {index} changed since the list was issued; list again"))
77        }
78        Ok(Err(AdoptError::UnknownList)) => WebReturnType::error("unknown-list: the list expired or is not this agent's; list again"),
79        Ok(Err(AdoptError::BadChoice)) => WebReturnType::error("bad-choice"),
80        Ok(Err(AdoptError::Busy)) => WebReturnType::error("busy: the agent's memory is being synced; try again"),
81        Ok(Err(AdoptError::Store(e))) => {
82            tracing::warn!("[memoryadopt] Adopt failed: {e}");
83            WebReturnType::error("store error")
84        }
85        Err(e) => WebReturnType::error(format!("memoryadopt.Adopt: {e}")),
86    }
87}
88
89#[derive(Deserialize)]
90struct ReleaseArgs {
91    agent_id: String,
92    list_id: String,
93    index: usize,
94}
95
96/// Release an agent's claim on a folder from its `agent:memory:claims`
97/// list — a human confirmed it in the host window (spec §2.1.2).
98async fn handle_release(call: &WebCallType) -> WebReturnType {
99    use crate::backend::memory_release::{self, ReleaseError};
100    let args: ReleaseArgs = match get_arg(&call.args, 0) {
101        Ok(a) => a,
102        Err(e) => return WebReturnType::error(format!("memoryadopt.Release: {e}")),
103    };
104    let Some(fs) = crate::backend::agent_session::global_transcript_store() else {
105        return WebReturnType::error("memoryadopt.Release: memory record store unavailable");
106    };
107    let result =
108        tokio::task::spawn_blocking(move || memory_release::release(fs, &args.agent_id, &args.list_id, args.index)).await;
109    match result {
110        Ok(Ok(released)) => WebReturnType::success(serde_json::json!({ "released": released })),
111        Ok(Err(ReleaseError::UnknownList)) => WebReturnType::error("unknown-list: the list expired or is not this agent's; list again"),
112        Ok(Err(ReleaseError::BadChoice)) => WebReturnType::error("bad-choice"),
113        Ok(Err(ReleaseError::Store(e))) => {
114            tracing::warn!("[memoryadopt] Release failed: {e}");
115            WebReturnType::error("store error")
116        }
117        Err(e) => WebReturnType::error(format!("memoryadopt.Release: {e}")),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn call(secret: Option<&str>) -> WebCallType {
126        let mut args = vec![serde_json::json!({ "agent_id": "a", "list_id": "l", "choices": [] })];
127        if let Some(s) = secret {
128            args.push(serde_json::json!(s));
129        }
130        WebCallType { service: "memoryadopt".into(), method: "Adopt".into(), uicontext: None, args }
131    }
132
133    /// The attack this closes: an agent holds `X-AuthKey` (every agent's
134    /// environment does) and calls Adopt itself, skipping the human's
135    /// confirmation in the host window. It can't produce the host secret.
136    #[tokio::test]
137    async fn an_agent_without_the_host_secret_cannot_adopt() {
138        let state = crate::server::tests::test_state();
139        for secret in [None, Some(""), Some("not-the-secret")] {
140            let r = handle_memory_adopt_service(&state, &call(secret)).await;
141            assert_eq!(r.error.as_deref(), Some("memoryadopt: host-only"), "{secret:?}");
142        }
143    }
144
145    #[tokio::test]
146    async fn an_agent_without_the_host_secret_cannot_release_a_folder() {
147        let state = crate::server::tests::test_state();
148        let call = WebCallType {
149            service: "memoryadopt".into(),
150            method: "Release".into(),
151            uicontext: None,
152            args: vec![serde_json::json!({ "agent_id": "a", "list_id": "l", "index": 0 })],
153        };
154        let r = handle_memory_adopt_service(&state, &call).await;
155        assert_eq!(r.error.as_deref(), Some("memoryadopt: host-only"));
156    }
157
158    #[tokio::test]
159    async fn the_host_reaches_adopt() {
160        let state = crate::server::tests::test_state();
161        let r = handle_memory_adopt_service(&state, &call(Some("test-host-reg-secret"))).await;
162        assert_ne!(r.error.as_deref(), Some("memoryadopt: host-only"));
163    }
164}