agentmux_srv\registry/
schema.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Registry file format + per-row validation.
5//!
6//! Bumping `MAX_SUPPORTED_SCHEMA` is the additive-evolution path:
7//! readers of the previous bound still skip-and-log new files; old
8//! disk files keep validating because the v1 reader stays intact.
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Lowest envelope schema this binary will load. Bumped only with a
14/// deprecation cycle (see SPEC §6).
15pub const MIN_SUPPORTED_SCHEMA: u32 = 1;
16/// Highest envelope schema this binary will write or read. Bumped
17/// per release that adds fields.
18pub const MAX_SUPPORTED_SCHEMA: u32 = 1;
19
20/// On-disk envelope. The `data` field's shape is gated by
21/// `schema_version`; readers should match on the version before
22/// projecting.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct NamedAgentRecord {
25    pub schema_version: u32,
26    pub data: NamedAgentRecordV1,
27}
28
29/// v1 payload. Add new optional fields here under `#[serde(default)]`
30/// and bump `MAX_SUPPORTED_SCHEMA` — old readers will skip the new
31/// version, new readers fill defaults for old files.
32#[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    /// FK to `db_identity_bundles.id`. None = unbound (= ambient creds).
38    pub identity_id: Option<String>,
39    /// FK to `db_memory_bundles.id`. None = unbound (= vanilla CLI).
40    pub memory_id: Option<String>,
41    /// Path **relative to `<shared_home>/agents/`** — never absolute.
42    /// Keeps the record portable across machines where the home dir
43    /// differs.
44    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
70/// Per-row validation. Fails fast on anything that would let a
71/// malformed file be returned to the launch modal. Validation
72/// failures are skipped (not auto-fixed), logged, and the file stays
73/// on disk for ops triage.
74pub 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}