1use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::typed_id::{AgentId, MemoryId};
19
20#[cfg(feature = "openapi")]
21use utoipa::ToSchema;
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[cfg_attr(feature = "openapi", derive(ToSchema))]
33#[serde(rename_all = "lowercase")]
34pub enum MemoryStatus {
35 Active,
36 Archived,
37 Deleted,
38}
39
40impl std::fmt::Display for MemoryStatus {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 MemoryStatus::Active => write!(f, "active"),
44 MemoryStatus::Archived => write!(f, "archived"),
45 MemoryStatus::Deleted => write!(f, "deleted"),
46 }
47 }
48}
49
50impl From<&str> for MemoryStatus {
51 fn from(s: &str) -> Self {
52 match s {
53 "archived" => MemoryStatus::Archived,
54 "deleted" => MemoryStatus::Deleted,
55 _ => MemoryStatus::Active,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
62#[cfg_attr(feature = "openapi", derive(ToSchema))]
63#[serde(rename_all = "lowercase")]
64pub enum MemoryScope {
65 #[default]
67 Org,
68 Agent,
70 User,
72}
73
74impl std::fmt::Display for MemoryScope {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 MemoryScope::Org => write!(f, "org"),
78 MemoryScope::Agent => write!(f, "agent"),
79 MemoryScope::User => write!(f, "user"),
80 }
81 }
82}
83
84impl From<&str> for MemoryScope {
85 fn from(s: &str) -> Self {
86 match s {
87 "agent" => MemoryScope::Agent,
88 "user" => MemoryScope::User,
89 _ => MemoryScope::Org,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96#[cfg_attr(feature = "openapi", derive(ToSchema))]
97pub struct Memory {
98 #[serde(rename = "id")]
100 #[cfg_attr(
101 feature = "openapi",
102 schema(value_type = String, example = "mem_01933b5a000070008000000000000001")
103 )]
104 pub public_id: MemoryId,
105 #[serde(skip, default = "Uuid::nil")]
107 pub internal_id: Uuid,
108 pub name: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub description: Option<String>,
113 #[serde(default)]
115 pub scope: MemoryScope,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub owner_agent_id: Option<AgentId>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub owner_user_id: Option<Uuid>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub owner_principal_id: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub resolved_owner_user_id: Option<Uuid>,
128 pub status: MemoryStatus,
130 pub created_at: DateTime<Utc>,
131 pub updated_at: DateTime<Utc>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub archived_at: Option<DateTime<Utc>>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub deleted_at: Option<DateTime<Utc>>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(ToSchema))]
145pub struct MemoryFile {
146 pub id: Uuid,
147 pub memory_id: Uuid,
149 pub path: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
154 pub content: Option<String>,
155 #[serde(default = "default_encoding")]
157 pub encoding: String,
158 pub is_directory: bool,
159 pub size_bytes: i64,
160 #[serde(skip_serializing_if = "Option::is_none")]
163 pub content_hash: Option<String>,
164 pub created_at: DateTime<Utc>,
165 pub updated_at: DateTime<Utc>,
166}
167
168fn default_encoding() -> String {
169 "text".to_string()
170}
171
172#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
174#[cfg_attr(feature = "openapi", derive(ToSchema))]
175#[serde(rename_all = "lowercase")]
176pub enum MemoryMountAccess {
177 #[default]
178 ReadOnly,
179 ReadWrite,
180}
181
182impl std::fmt::Display for MemoryMountAccess {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 match self {
185 MemoryMountAccess::ReadOnly => write!(f, "readonly"),
186 MemoryMountAccess::ReadWrite => write!(f, "readwrite"),
187 }
188 }
189}
190
191impl From<&str> for MemoryMountAccess {
192 fn from(s: &str) -> Self {
193 match s {
194 "readwrite" => MemoryMountAccess::ReadWrite,
195 _ => MemoryMountAccess::ReadOnly,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[cfg_attr(feature = "openapi", derive(ToSchema))]
209pub struct MemoryMountConfig {
210 pub memory: String,
212 pub path: String,
214 #[serde(default)]
216 pub mode: MemoryMountAccess,
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[cfg_attr(feature = "openapi", derive(ToSchema))]
222pub struct MemoryConfig {
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub mounts: Vec<MemoryMountConfig>,
225}
226
227pub fn validate_mount_config_shape(mount: &MemoryMountConfig) -> Result<(), String> {
235 if MemoryId::parse(&mount.memory).is_err() {
240 return Err(format!(
241 "mount.memory must be a valid Memory ID of the form mem_<32-lowercase-hex>, got '{}'",
242 mount.memory
243 ));
244 }
245
246 let path = &mount.path;
250 if path != "/workspace" && !path.starts_with("/workspace/") {
251 return Err(format!(
252 "mount.path must be /workspace or start with /workspace/, got '{path}'"
253 ));
254 }
255 if path.contains("//") {
256 return Err(format!("mount.path must not contain '//', got '{path}'"));
257 }
258 if path.contains('\0') {
259 return Err(format!(
260 "mount.path must not contain null bytes, got '{path}'"
261 ));
262 }
263 if path.split('/').any(|seg| seg == "..") {
264 return Err(format!("mount.path must not contain '..', got '{path}'"));
265 }
266 if path.len() > 1 && path.ends_with('/') {
267 return Err(format!(
268 "mount.path must not end with a trailing slash, got '{path}'"
269 ));
270 }
271 Ok(())
272}
273
274pub fn validate_memory_config(config: &MemoryConfig) -> Result<(), String> {
277 for mount in &config.mounts {
278 validate_mount_config_shape(mount)?;
279 }
280 let mut seen: Vec<&str> = Vec::with_capacity(config.mounts.len());
282 for mount in &config.mounts {
283 if seen.iter().any(|p| *p == mount.path) {
284 return Err(format!(
285 "duplicate mount path '{}' in memory config",
286 mount.path
287 ));
288 }
289 seen.push(&mount.path);
290 }
291 for (i, a) in config.mounts.iter().enumerate() {
293 for b in &config.mounts[i + 1..] {
294 if mount_paths_overlap(&a.path, &b.path) {
295 return Err(format!(
296 "overlapping mount paths '{}' and '{}'",
297 a.path, b.path
298 ));
299 }
300 }
301 }
302 Ok(())
303}
304
305fn mount_paths_overlap(a: &str, b: &str) -> bool {
306 if a == b {
307 return true;
308 }
309 let (shorter, longer) = if a.len() < b.len() { (a, b) } else { (b, a) };
310 longer.starts_with(shorter) && longer.as_bytes().get(shorter.len()) == Some(&b'/')
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn status_round_trip() {
319 assert_eq!(MemoryStatus::from("active").to_string(), "active");
320 assert_eq!(MemoryStatus::from("archived").to_string(), "archived");
321 assert_eq!(MemoryStatus::from("deleted").to_string(), "deleted");
322 assert_eq!(MemoryStatus::from("unknown").to_string(), "active");
323 }
324
325 #[test]
326 fn access_default_is_readonly() {
327 let cfg: MemoryMountConfig = serde_json::from_str(
328 r#"{ "memory": "mem_00000000000000000000000000000001", "path": "/workspace/r" }"#,
329 )
330 .unwrap();
331 assert_eq!(cfg.mode, MemoryMountAccess::ReadOnly);
332 }
333
334 #[test]
335 fn validate_rejects_non_mem_prefix() {
336 let cfg = MemoryMountConfig {
337 memory: "agent_x".into(),
338 path: "/workspace/r".into(),
339 mode: MemoryMountAccess::ReadOnly,
340 };
341 assert!(validate_mount_config_shape(&cfg).is_err());
342 }
343
344 #[test]
345 fn validate_rejects_path_outside_workspace() {
346 let cfg = MemoryMountConfig {
347 memory: "mem_00000000000000000000000000000001".into(),
348 path: "/etc/passwd".into(),
349 mode: MemoryMountAccess::ReadOnly,
350 };
351 assert!(validate_mount_config_shape(&cfg).is_err());
352 }
353
354 #[test]
355 fn validate_rejects_workspace_prefix_lookalike() {
356 let cfg = MemoryMountConfig {
358 memory: "mem_00000000000000000000000000000001".into(),
359 path: "/workspacefoo".into(),
360 mode: MemoryMountAccess::ReadOnly,
361 };
362 assert!(validate_mount_config_shape(&cfg).is_err());
363 }
364
365 #[test]
366 fn validate_accepts_workspace_root() {
367 let cfg = MemoryMountConfig {
368 memory: "mem_00000000000000000000000000000001".into(),
369 path: "/workspace".into(),
370 mode: MemoryMountAccess::ReadOnly,
371 };
372 assert!(validate_mount_config_shape(&cfg).is_ok());
373 }
374
375 #[test]
376 fn validate_rejects_invalid_hex_in_memory_id() {
377 let cfg = MemoryMountConfig {
380 memory: "mem_not-hex".into(),
381 path: "/workspace/r".into(),
382 mode: MemoryMountAccess::ReadOnly,
383 };
384 assert!(validate_mount_config_shape(&cfg).is_err());
385 }
386
387 #[test]
388 fn validate_rejects_dotdot() {
389 let cfg = MemoryMountConfig {
390 memory: "mem_00000000000000000000000000000001".into(),
391 path: "/workspace/../etc".into(),
392 mode: MemoryMountAccess::ReadOnly,
393 };
394 assert!(validate_mount_config_shape(&cfg).is_err());
395 }
396
397 #[test]
398 fn validate_rejects_double_slash() {
399 let cfg = MemoryMountConfig {
400 memory: "mem_00000000000000000000000000000001".into(),
401 path: "/workspace//data".into(),
402 mode: MemoryMountAccess::ReadOnly,
403 };
404 assert!(validate_mount_config_shape(&cfg).is_err());
405 }
406
407 #[test]
408 fn validate_rejects_trailing_slash() {
409 let cfg = MemoryMountConfig {
410 memory: "mem_00000000000000000000000000000001".into(),
411 path: "/workspace/data/".into(),
412 mode: MemoryMountAccess::ReadOnly,
413 };
414 assert!(validate_mount_config_shape(&cfg).is_err());
415 }
416
417 #[test]
418 fn validate_accepts_valid_mount() {
419 let cfg = MemoryMountConfig {
420 memory: "mem_00000000000000000000000000000001".into(),
421 path: "/workspace/research".into(),
422 mode: MemoryMountAccess::ReadOnly,
423 };
424 assert!(validate_mount_config_shape(&cfg).is_ok());
425 }
426
427 #[test]
428 fn config_validate_rejects_duplicate_paths() {
429 let cfg = MemoryConfig {
430 mounts: vec![
431 MemoryMountConfig {
432 memory: "mem_00000000000000000000000000000001".into(),
433 path: "/workspace/data".into(),
434 mode: MemoryMountAccess::ReadOnly,
435 },
436 MemoryMountConfig {
437 memory: "mem_00000000000000000000000000000002".into(),
438 path: "/workspace/data".into(),
439 mode: MemoryMountAccess::ReadWrite,
440 },
441 ],
442 };
443 let err = validate_memory_config(&cfg).unwrap_err();
444 assert!(err.contains("duplicate"));
445 }
446
447 #[test]
448 fn config_validate_rejects_overlapping_paths() {
449 let cfg = MemoryConfig {
450 mounts: vec![
451 MemoryMountConfig {
452 memory: "mem_00000000000000000000000000000001".into(),
453 path: "/workspace/data".into(),
454 mode: MemoryMountAccess::ReadOnly,
455 },
456 MemoryMountConfig {
457 memory: "mem_00000000000000000000000000000002".into(),
458 path: "/workspace/data/sub".into(),
459 mode: MemoryMountAccess::ReadWrite,
460 },
461 ],
462 };
463 let err = validate_memory_config(&cfg).unwrap_err();
464 assert!(err.contains("overlapping"));
465 }
466
467 #[test]
468 fn config_validate_accepts_distinct_paths() {
469 let cfg = MemoryConfig {
470 mounts: vec![
471 MemoryMountConfig {
472 memory: "mem_00000000000000000000000000000001".into(),
473 path: "/workspace/data".into(),
474 mode: MemoryMountAccess::ReadOnly,
475 },
476 MemoryMountConfig {
477 memory: "mem_00000000000000000000000000000002".into(),
478 path: "/workspace/notes".into(),
479 mode: MemoryMountAccess::ReadWrite,
480 },
481 ],
482 };
483 assert!(validate_memory_config(&cfg).is_ok());
484 }
485
486 #[test]
487 fn overlap_helper_does_not_match_unrelated_prefix() {
488 assert!(!mount_paths_overlap(
490 "/workspace/data",
491 "/workspace/datasets"
492 ));
493 }
494}