1use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const MIN_SUPPORTED_SCHEMA: u32 = 1;
16pub const MAX_SUPPORTED_SCHEMA: u32 = 3;
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>,
43 pub memory_id: Option<String>,
45 #[serde(default)]
51 pub session_id: Option<String>,
52 pub working_dir: String,
57 #[serde(default)]
67 pub source_agents_base: Option<String>,
68 pub created_at_ms: i64,
69 pub last_launched_at_ms: i64,
70 pub created_by_version: String,
71 pub last_launched_by_version: String,
72}
73
74impl NamedAgentRecordV1 {
75 pub fn min_schema_version(&self) -> u32 {
86 if self.source_agents_base.is_some() {
87 3
88 } else if self.session_id.is_some() {
89 2
90 } else {
91 1
92 }
93 }
94}
95
96#[derive(Debug, Error)]
97pub enum ValidationError {
98 #[error("schema_version {version} outside supported [{min}, {max}]")]
99 UnsupportedSchema {
100 version: u32,
101 min: u32,
102 max: u32,
103 },
104 #[error("filename UUID {filename:?} does not match data.instance_id {instance_id:?}")]
105 IdMismatch {
106 filename: String,
107 instance_id: String,
108 },
109 #[error("working_dir {0:?} is not a safe relative subpath of agents/")]
110 UnsafeWorkingDir(String),
111 #[error("required field missing: {0}")]
112 MissingField(&'static str),
113}
114
115pub fn validate(filename_stem: &str, rec: &NamedAgentRecord) -> Result<(), ValidationError> {
120 if rec.schema_version < MIN_SUPPORTED_SCHEMA || rec.schema_version > MAX_SUPPORTED_SCHEMA {
121 return Err(ValidationError::UnsupportedSchema {
122 version: rec.schema_version,
123 min: MIN_SUPPORTED_SCHEMA,
124 max: MAX_SUPPORTED_SCHEMA,
125 });
126 }
127 let d = &rec.data;
128 if d.instance_id.is_empty() {
129 return Err(ValidationError::MissingField("instance_id"));
130 }
131 if d.instance_id != filename_stem {
132 return Err(ValidationError::IdMismatch {
133 filename: filename_stem.to_string(),
134 instance_id: d.instance_id.clone(),
135 });
136 }
137 if d.instance_name.is_empty() {
138 return Err(ValidationError::MissingField("instance_name"));
139 }
140 if d.definition_id.is_empty() {
141 return Err(ValidationError::MissingField("definition_id"));
142 }
143 if d.working_dir.is_empty() {
144 return Err(ValidationError::MissingField("working_dir"));
145 }
146 if !is_safe_relative_subpath(&d.working_dir) {
147 return Err(ValidationError::UnsafeWorkingDir(d.working_dir.clone()));
148 }
149 Ok(())
150}
151
152fn is_safe_relative_subpath(s: &str) -> bool {
153 let p = std::path::Path::new(s);
154 if p.is_absolute() {
155 return false;
156 }
157 for comp in p.components() {
158 match comp {
159 std::path::Component::ParentDir
160 | std::path::Component::RootDir
161 | std::path::Component::Prefix(_) => return false,
162 _ => {}
163 }
164 }
165 true
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn v1_record(id: &str) -> NamedAgentRecord {
173 NamedAgentRecord {
174 schema_version: 1,
175 data: NamedAgentRecordV1 {
176 instance_id: id.to_string(),
177 instance_name: "demo".to_string(),
178 definition_id: "claude-code".to_string(),
179 identity_id: None,
180 memory_id: None,
181 session_id: None,
182 working_dir: "demo-0512a".to_string(),
183 source_agents_base: None,
184 created_at_ms: 1,
185 last_launched_at_ms: 1,
186 created_by_version: "0.33.822".to_string(),
187 last_launched_by_version: "0.33.822".to_string(),
188 },
189 }
190 }
191
192 #[test]
193 fn happy_path() {
194 let r = v1_record("abc");
195 validate("abc", &r).unwrap();
196 }
197
198 #[test]
199 fn unsupported_schema_is_rejected() {
200 let mut r = v1_record("abc");
201 r.schema_version = 999;
202 let err = validate("abc", &r).unwrap_err();
203 assert!(matches!(err, ValidationError::UnsupportedSchema { .. }));
204 }
205
206 #[test]
207 fn filename_mismatch_is_rejected() {
208 let r = v1_record("abc");
209 assert!(matches!(
210 validate("xyz", &r).unwrap_err(),
211 ValidationError::IdMismatch { .. }
212 ));
213 }
214
215 #[test]
216 fn absolute_workdir_is_rejected() {
217 let mut r = v1_record("abc");
218 r.data.working_dir = if cfg!(windows) {
219 "C:\\tmp\\evil".to_string()
220 } else {
221 "/tmp/evil".to_string()
222 };
223 assert!(matches!(
224 validate("abc", &r).unwrap_err(),
225 ValidationError::UnsafeWorkingDir(_)
226 ));
227 }
228
229 #[test]
230 fn dotdot_workdir_is_rejected() {
231 let mut r = v1_record("abc");
232 r.data.working_dir = "../../sneaky".to_string();
233 assert!(matches!(
234 validate("abc", &r).unwrap_err(),
235 ValidationError::UnsafeWorkingDir(_)
236 ));
237 }
238
239 #[test]
240 fn missing_required_field_is_rejected() {
241 let mut r = v1_record("abc");
242 r.data.instance_name = String::new();
243 assert!(matches!(
244 validate("abc", &r).unwrap_err(),
245 ValidationError::MissingField("instance_name")
246 ));
247 }
248
249 #[test]
250 fn session_id_drives_min_schema_version() {
251 let mut r = v1_record("abc");
252 assert_eq!(r.data.min_schema_version(), 1, "session-less record is v1");
253 r.data.session_id = Some("sess-xyz".to_string());
254 assert_eq!(r.data.min_schema_version(), 2, "session-wired record is v2");
255 r.schema_version = 2;
257 validate("abc", &r).unwrap();
258 }
259
260 #[test]
261 fn source_agents_base_drives_min_schema_version_v3() {
262 let mut r = v1_record("abc");
263 assert_eq!(r.data.min_schema_version(), 1);
264 r.data.source_agents_base = Some("/home/u/.agentmux/channels/stable/agents".to_string());
265 assert_eq!(
266 r.data.min_schema_version(),
267 3,
268 "a source-anchored record needs v3"
269 );
270 r.data.session_id = Some("sess-1".to_string());
272 assert_eq!(r.data.min_schema_version(), 3);
273 r.schema_version = 3;
275 validate("abc", &r).unwrap();
276 }
277
278 #[test]
279 fn unknown_future_field_round_trips() {
280 let raw = serde_json::json!({
284 "schema_version": 3,
285 "data": {
286 "instance_id": "abc", "instance_name": "demo",
287 "definition_id": "claude-code", "identity_id": null,
288 "memory_id": null, "working_dir": "demo-0",
289 "source_agents_base": "/h/.agentmux/channels/x/agents",
290 "created_at_ms": 1, "last_launched_at_ms": 1,
291 "created_by_version": "0.45.0", "last_launched_by_version": "0.45.0",
292 "future_field": "ignored"
293 }
294 });
295 let parsed: NamedAgentRecord = serde_json::from_value(raw).unwrap();
296 assert_eq!(parsed.data.instance_id, "abc");
297 assert_eq!(
298 parsed.data.source_agents_base.as_deref(),
299 Some("/h/.agentmux/channels/x/agents")
300 );
301 }
302}