agentmux_srv\registry/
schema.rs1use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const MIN_SUPPORTED_SCHEMA: u32 = 1;
16pub const MAX_SUPPORTED_SCHEMA: u32 = 1;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct NamedAgentRecord {
25 pub schema_version: u32,
26 pub data: NamedAgentRecordV1,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct NamedAgentRecordV1 {
34 pub instance_id: String,
35 pub instance_name: String,
36 pub definition_id: String,
37 pub identity_id: Option<String>,
39 pub memory_id: Option<String>,
41 pub working_dir: String,
45 pub created_at_ms: i64,
46 pub last_launched_at_ms: i64,
47 pub created_by_version: String,
48 pub last_launched_by_version: String,
49}
50
51#[derive(Debug, Error)]
52pub enum ValidationError {
53 #[error("schema_version {version} outside supported [{min}, {max}]")]
54 UnsupportedSchema {
55 version: u32,
56 min: u32,
57 max: u32,
58 },
59 #[error("filename UUID {filename:?} does not match data.instance_id {instance_id:?}")]
60 IdMismatch {
61 filename: String,
62 instance_id: String,
63 },
64 #[error("working_dir {0:?} is not a safe relative subpath of agents/")]
65 UnsafeWorkingDir(String),
66 #[error("required field missing: {0}")]
67 MissingField(&'static str),
68}
69
70pub fn validate(filename_stem: &str, rec: &NamedAgentRecord) -> Result<(), ValidationError> {
75 if rec.schema_version < MIN_SUPPORTED_SCHEMA || rec.schema_version > MAX_SUPPORTED_SCHEMA {
76 return Err(ValidationError::UnsupportedSchema {
77 version: rec.schema_version,
78 min: MIN_SUPPORTED_SCHEMA,
79 max: MAX_SUPPORTED_SCHEMA,
80 });
81 }
82 let d = &rec.data;
83 if d.instance_id.is_empty() {
84 return Err(ValidationError::MissingField("instance_id"));
85 }
86 if d.instance_id != filename_stem {
87 return Err(ValidationError::IdMismatch {
88 filename: filename_stem.to_string(),
89 instance_id: d.instance_id.clone(),
90 });
91 }
92 if d.instance_name.is_empty() {
93 return Err(ValidationError::MissingField("instance_name"));
94 }
95 if d.definition_id.is_empty() {
96 return Err(ValidationError::MissingField("definition_id"));
97 }
98 if d.working_dir.is_empty() {
99 return Err(ValidationError::MissingField("working_dir"));
100 }
101 if !is_safe_relative_subpath(&d.working_dir) {
102 return Err(ValidationError::UnsafeWorkingDir(d.working_dir.clone()));
103 }
104 Ok(())
105}
106
107fn is_safe_relative_subpath(s: &str) -> bool {
108 let p = std::path::Path::new(s);
109 if p.is_absolute() {
110 return false;
111 }
112 for comp in p.components() {
113 match comp {
114 std::path::Component::ParentDir
115 | std::path::Component::RootDir
116 | std::path::Component::Prefix(_) => return false,
117 _ => {}
118 }
119 }
120 true
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn v1_record(id: &str) -> NamedAgentRecord {
128 NamedAgentRecord {
129 schema_version: 1,
130 data: NamedAgentRecordV1 {
131 instance_id: id.to_string(),
132 instance_name: "demo".to_string(),
133 definition_id: "claude-code".to_string(),
134 identity_id: None,
135 memory_id: None,
136 working_dir: "demo-0512a".to_string(),
137 created_at_ms: 1,
138 last_launched_at_ms: 1,
139 created_by_version: "0.33.822".to_string(),
140 last_launched_by_version: "0.33.822".to_string(),
141 },
142 }
143 }
144
145 #[test]
146 fn happy_path() {
147 let r = v1_record("abc");
148 validate("abc", &r).unwrap();
149 }
150
151 #[test]
152 fn unsupported_schema_is_rejected() {
153 let mut r = v1_record("abc");
154 r.schema_version = 999;
155 let err = validate("abc", &r).unwrap_err();
156 assert!(matches!(err, ValidationError::UnsupportedSchema { .. }));
157 }
158
159 #[test]
160 fn filename_mismatch_is_rejected() {
161 let r = v1_record("abc");
162 assert!(matches!(
163 validate("xyz", &r).unwrap_err(),
164 ValidationError::IdMismatch { .. }
165 ));
166 }
167
168 #[test]
169 fn absolute_workdir_is_rejected() {
170 let mut r = v1_record("abc");
171 r.data.working_dir = if cfg!(windows) {
172 "C:\\tmp\\evil".to_string()
173 } else {
174 "/tmp/evil".to_string()
175 };
176 assert!(matches!(
177 validate("abc", &r).unwrap_err(),
178 ValidationError::UnsafeWorkingDir(_)
179 ));
180 }
181
182 #[test]
183 fn dotdot_workdir_is_rejected() {
184 let mut r = v1_record("abc");
185 r.data.working_dir = "..\\..\\sneaky".to_string();
186 assert!(matches!(
187 validate("abc", &r).unwrap_err(),
188 ValidationError::UnsafeWorkingDir(_)
189 ));
190 }
191
192 #[test]
193 fn missing_required_field_is_rejected() {
194 let mut r = v1_record("abc");
195 r.data.instance_name = String::new();
196 assert!(matches!(
197 validate("abc", &r).unwrap_err(),
198 ValidationError::MissingField("instance_name")
199 ));
200 }
201}