agentmux_srv\server/
native_memory_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native memory RPCs — read/write the agent's `~/.claude/projects/<sanitized>/memory/`
5//! folder that Claude Code uses for autonomous, cross-session fact storage.
6//!
7//! Three commands:
8//!   agent:memory:list       — list *.md files in the memory dir
9//!   agent:memory:read_file  — read one file by filename (no path traversal)
10//!   agent:memory:write_file — write/create one file atomically (tmp→rename)
11//!
12//! Spec: docs/specs/SPEC_AGENT_PANE_MEMORY_IDENTITY_MODALS_2026_06_19.md §7
13
14use std::sync::Arc;
15use std::path::PathBuf;
16
17use crate::backend::base::expand_home_dir_safe;
18use crate::backend::rpc::engine::WshRpcEngine;
19use crate::backend::rpc_types::{
20    COMMAND_NATIVE_MEMORY_LIST,
21    COMMAND_NATIVE_MEMORY_READ_FILE,
22    COMMAND_NATIVE_MEMORY_WRITE_FILE,
23    CommandNativeMemoryListData,
24    CommandNativeMemoryReadFileData,
25    CommandNativeMemoryWriteFileData,
26    NativeMemoryFileMeta,
27    NativeMemoryListResult,
28    NativeMemoryReadFileResult,
29};
30
31use super::AppState;
32
33/// Compute `~/.claude/projects/<sanitized>/memory/` for the given working directory.
34///
35/// Mirrors Claude Code's `sessionStoragePortable.ts` algorithm:
36/// 1. Replace every non-alphanumeric char with `-`.
37/// 2. If the result is longer than 200 chars, truncate at 200 and append a
38///    base-36 hash of the *raw* working_directory (before sanitization) as a suffix.
39fn memory_dir_for_cwd(working_directory: &str) -> PathBuf {
40    let sanitized: String = working_directory
41        .chars()
42        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
43        .collect();
44
45    // Spec §5.2: hash is computed on the raw working_directory (before sanitization).
46    let folder_name = if sanitized.len() > 200 {
47        let hash = djb2_hash(working_directory);
48        let truncated = &sanitized[..200];
49        format!("{truncated}-{}", radix_36(hash))
50    } else {
51        sanitized
52    };
53
54    expand_home_dir_safe("~/.claude/projects")
55        .join(folder_name)
56        .join("memory")
57}
58
59/// Hash matching Claude Code's sessionStoragePortable.ts implementation.
60/// JS `charCodeAt()` iterates UTF-16 code units (two surrogates per non-BMP char);
61/// Rust `chars()` iterates Unicode scalar values — they diverge for emoji/non-BMP.
62/// `encode_utf16()` produces the same UTF-16 unit stream as JS, so the hashes match.
63fn djb2_hash(s: &str) -> u32 {
64    let mut hash: i32 = 0;
65    for unit in s.encode_utf16() {
66        hash = hash.wrapping_shl(5).wrapping_sub(hash).wrapping_add(unit as i32);
67    }
68    hash.unsigned_abs()
69}
70
71/// Convert a u32 to a base-36 string (lowercase, same as Number.toString(36) in JS).
72fn radix_36(mut n: u32) -> String {
73    if n == 0 {
74        return "0".to_string();
75    }
76    const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
77    let mut buf = Vec::new();
78    while n > 0 {
79        buf.push(DIGITS[(n % 36) as usize]);
80        n /= 36;
81    }
82    buf.reverse();
83    String::from_utf8(buf).unwrap_or_else(|_| "0".to_string())
84}
85
86/// Validate a filename: alphanumeric + `-_`, must end with `.md`, no path separators.
87fn validate_filename(filename: &str) -> Result<(), String> {
88    if filename.is_empty() {
89        return Err("filename must not be empty".to_string());
90    }
91    if !filename.ends_with(".md") {
92        return Err(format!("filename must end with .md, got: {filename}"));
93    }
94    if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
95        return Err(format!("filename must not contain path separators: {filename}"));
96    }
97    let stem = &filename[..filename.len() - 3];
98    if stem.is_empty() {
99        return Err("filename stem must not be empty (.md is not a valid name)".to_string());
100    }
101    // Tmp path is ".{filename}.{uuid}.tmp" (+42 chars); cap stem at 200 to stay
102    // well under the 255-byte filesystem limit and avoid ENAMETOOLONG.
103    if stem.len() > 200 {
104        return Err(format!("filename stem too long ({} chars, max 200)", stem.len()));
105    }
106    if !stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
107        return Err(format!(
108            "filename stem must be alphanumeric + '-_', got: {stem}"
109        ));
110    }
111    Ok(())
112}
113
114/// Extract `metadata.type` from YAML frontmatter.
115/// Claude Code memory files nest the type under `metadata:`:
116///   metadata:
117///     type: user
118/// A top-level `type:` key is NOT the correct field.
119fn parse_frontmatter_type(content: &str) -> Option<String> {
120    let content = content.trim_start();
121    if !content.starts_with("---") {
122        return None;
123    }
124    let rest = content.strip_prefix("---")?.trim_start_matches('\n');
125    let end = rest.find("\n---")?;
126    let frontmatter = &rest[..end];
127    let mut in_metadata = false;
128    for line in frontmatter.lines() {
129        let trimmed = line.trim_end();
130        if trimmed == "metadata:" {
131            in_metadata = true;
132            continue;
133        }
134        if in_metadata {
135            // A non-indented, non-empty line exits the metadata block.
136            if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
137                break;
138            }
139            if let Some(val) = line.trim_start().strip_prefix("type:") {
140                let val = val.trim().trim_matches('"').trim_matches('\'');
141                if !val.is_empty() {
142                    return Some(val.to_string());
143                }
144            }
145        }
146    }
147    None
148}
149
150pub fn register_native_memory_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
151    let wstore_list = state.wstore.clone();
152    engine.register_handler(
153        COMMAND_NATIVE_MEMORY_LIST,
154        Box::new(move |data, _ctx| {
155            let wstore = wstore_list.clone();
156            Box::pin(async move {
157                let cmd: CommandNativeMemoryListData = serde_json::from_value(data)
158                    .map_err(|e| format!("agent:memory:list: {e}"))?;
159
160                let agent = wstore
161                    .agent_def_get(&cmd.agent_id)
162                    .map_err(|e| format!("agent:memory:list: store: {e}"))?
163                    .ok_or_else(|| format!("agent:memory:list: agent {} not found", cmd.agent_id))?;
164
165                // No working directory → no memory path (avoid mapping to the shared
166                // ~/.claude/projects/memory/ directory — reagent P1 on PR #1588).
167                if agent.working_directory.is_empty() {
168                    return Ok(Some(serde_json::to_value(NativeMemoryListResult { files: vec![] }).map_err(|e| e.to_string())?));
169                }
170
171                let memory_dir = memory_dir_for_cwd(&agent.working_directory);
172
173                let mut files: Vec<NativeMemoryFileMeta> = Vec::new();
174                // Treat both "dir doesn't exist" and the TOCTOU case where the dir
175                // is deleted between exists() and read_dir() as an empty result.
176                let entries = match std::fs::read_dir(&memory_dir) {
177                    Ok(e) => e,
178                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
179                        return Ok(Some(serde_json::to_value(NativeMemoryListResult { files: vec![] }).map_err(|e| e.to_string())?));
180                    }
181                    Err(e) => return Err(format!("agent:memory:list: read_dir: {e}")),
182                };
183
184                for entry in entries {
185                    let entry = entry.map_err(|e| format!("agent:memory:list: read_dir entry: {e}"))?;
186                    let name = entry.file_name().to_string_lossy().into_owned();
187                    if !name.ends_with(".md") {
188                        continue;
189                    }
190                    // Reject symlinks — entry.file_type() does NOT follow symlinks.
191                    let file_type = entry
192                        .file_type()
193                        .map_err(|e| format!("agent:memory:list: file_type {name}: {e}"))?;
194                    if !file_type.is_file() {
195                        continue;
196                    }
197                    // The file may be deleted after read_dir but before metadata —
198                    // skip the entry on NotFound rather than aborting the whole listing.
199                    let meta = match entry.metadata() {
200                        Ok(m) => m,
201                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
202                        Err(e) => return Err(format!("agent:memory:list: metadata {name}: {e}")),
203                    };
204                    let size_bytes = meta.len();
205                    let modified_at = meta
206                        .modified()
207                        .ok()
208                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
209                        .map(|d| d.as_millis() as i64)
210                        .unwrap_or(0);
211
212                    // Read up to 512 bytes for frontmatter type parsing.
213                    // Take + read_to_end loops internally to fill the buffer —
214                    // a single read() call may return fewer bytes on the first try.
215                    let preview_content = {
216                        use std::io::Read;
217                        std::fs::File::open(entry.path())
218                            .map(|f| {
219                                let mut buf = Vec::with_capacity(512);
220                                f.take(512).read_to_end(&mut buf).ok();
221                                String::from_utf8_lossy(&buf).into_owned()
222                            })
223                            .unwrap_or_default()
224                    };
225                    let metadata_type = parse_frontmatter_type(&preview_content);
226                    let is_index = name == "MEMORY.md";
227
228                    files.push(NativeMemoryFileMeta {
229                        filename: name,
230                        is_index,
231                        metadata_type,
232                        size_bytes,
233                        modified_at,
234                    });
235                }
236
237                // MEMORY.md first, then alphabetical
238                files.sort_by(|a, b| {
239                    b.is_index.cmp(&a.is_index).then(a.filename.cmp(&b.filename))
240                });
241
242                Ok(Some(serde_json::to_value(NativeMemoryListResult { files }).map_err(|e| e.to_string())?))
243            })
244        }),
245    );
246
247    let wstore_read = state.wstore.clone();
248    engine.register_handler(
249        COMMAND_NATIVE_MEMORY_READ_FILE,
250        Box::new(move |data, _ctx| {
251            let wstore = wstore_read.clone();
252            Box::pin(async move {
253                let cmd: CommandNativeMemoryReadFileData = serde_json::from_value(data)
254                    .map_err(|e| format!("agent:memory:read_file: {e}"))?;
255
256                validate_filename(&cmd.filename)
257                    .map_err(|e| format!("agent:memory:read_file: {e}"))?;
258
259                let agent = wstore
260                    .agent_def_get(&cmd.agent_id)
261                    .map_err(|e| format!("agent:memory:read_file: store: {e}"))?
262                    .ok_or_else(|| format!("agent:memory:read_file: agent {} not found", cmd.agent_id))?;
263
264                if agent.working_directory.is_empty() {
265                    return Err(format!("agent:memory:read_file: agent {} has no configured working directory", cmd.agent_id));
266                }
267
268                let path = memory_dir_for_cwd(&agent.working_directory).join(&cmd.filename);
269                // Reject symlinks — consistent with list handler's file_type check.
270                let file_type = std::fs::symlink_metadata(&path)
271                    .map_err(|e| format!("agent:memory:read_file: {}: {e}", cmd.filename))?
272                    .file_type();
273                if !file_type.is_file() {
274                    return Err(format!("agent:memory:read_file: {} is not a regular file", cmd.filename));
275                }
276                // Cap at 10 MiB; use read_to_end + from_utf8_lossy so a boundary
277                // mid-UTF-8 sequence doesn't surface as InvalidData.
278                const MAX_READ_BYTES: u64 = 10 * 1024 * 1024;
279                let mut buf = Vec::new();
280                std::fs::File::open(&path)
281                    .and_then(|f| {
282                        use std::io::Read;
283                        f.take(MAX_READ_BYTES).read_to_end(&mut buf)
284                    })
285                    .map_err(|e| format!("agent:memory:read_file: {}: {e}", cmd.filename))?;
286                let content = String::from_utf8_lossy(&buf).into_owned();
287
288                Ok(Some(serde_json::to_value(NativeMemoryReadFileResult { content }).map_err(|e| e.to_string())?))
289            })
290        }),
291    );
292
293    let wstore_write = state.wstore.clone();
294    engine.register_handler(
295        COMMAND_NATIVE_MEMORY_WRITE_FILE,
296        Box::new(move |data, _ctx| {
297            let wstore = wstore_write.clone();
298            Box::pin(async move {
299                let cmd: CommandNativeMemoryWriteFileData = serde_json::from_value(data)
300                    .map_err(|e| format!("agent:memory:write_file: {e}"))?;
301
302                validate_filename(&cmd.filename)
303                    .map_err(|e| format!("agent:memory:write_file: {e}"))?;
304
305                const MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
306                if cmd.content.len() > MAX_CONTENT_BYTES {
307                    return Err(format!(
308                        "agent:memory:write_file: content too large ({} bytes, max {})",
309                        cmd.content.len(),
310                        MAX_CONTENT_BYTES,
311                    ));
312                }
313
314                let agent = wstore
315                    .agent_def_get(&cmd.agent_id)
316                    .map_err(|e| format!("agent:memory:write_file: store: {e}"))?
317                    .ok_or_else(|| format!("agent:memory:write_file: agent {} not found", cmd.agent_id))?;
318
319                if agent.working_directory.is_empty() {
320                    return Err(format!("agent:memory:write_file: agent {} has no configured working directory", cmd.agent_id));
321                }
322
323                let dir = memory_dir_for_cwd(&agent.working_directory);
324                std::fs::create_dir_all(&dir)
325                    .map_err(|e| format!("agent:memory:write_file: mkdir: {e}"))?;
326
327                let dest = dir.join(&cmd.filename);
328                // Per-write UUID suffix prevents concurrent writes to the same
329                // filename from sharing a tmp path and silently corrupting each
330                // other's content (reagent P1 on PR #1588).
331                let tmp = dir.join(format!(".{}.{}.tmp", cmd.filename, uuid::Uuid::new_v4()));
332
333                // Clean up tmp on both write failure (partial file) and rename failure.
334                if let Err(e) = std::fs::write(&tmp, &cmd.content) {
335                    let _ = std::fs::remove_file(&tmp);
336                    return Err(format!("agent:memory:write_file: write tmp: {e}"));
337                }
338                if let Err(e) = std::fs::rename(&tmp, &dest) {
339                    let _ = std::fs::remove_file(&tmp);
340                    return Err(format!("agent:memory:write_file: rename: {e}"));
341                }
342
343                tracing::info!(
344                    agent_id = %cmd.agent_id,
345                    filename = %cmd.filename,
346                    bytes = cmd.content.len(),
347                    "agent:memory:write_file"
348                );
349                Ok(None)
350            })
351        }),
352    );
353}