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