Skip to main content

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 = 3;
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    /// Legacy Identity-bundle id — `db_identity_bundles` was dropped in
38    /// Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md, so this
39    /// field is now vestigial (opaque pass-through only; credential
40    /// resolution is `db_agent_identity_links`-only). None = unbound
41    /// (no account).
42    pub identity_id: Option<String>,
43    /// FK to `db_bundles.id`. None = unbound (= vanilla CLI).
44    pub memory_id: Option<String>,
45    /// Provider CLI session id for `--resume` (e.g. a Claude Code session
46    /// uuid). `None` = no session wired yet (fresh stub / legacy record).
47    /// Added in schema v2 so a global/migrated record can resume the
48    /// agent's conversation across channels without a current-channel
49    /// SQLite join. Old (v1) files deserialize this as `None`.
50    #[serde(default)]
51    pub session_id: Option<String>,
52    /// Path **relative to [`source_agents_base`]** (or, for legacy records
53    /// without one, the reader's current channel agents dir) — never
54    /// absolute. Keeps the record portable across machines where the home
55    /// dir differs.
56    pub working_dir: String,
57    /// Absolute path of the agents dir that [`working_dir`] is relative to —
58    /// i.e. the channel/dev instance the agent actually lives in
59    /// (`channels/<ch>/agents` or `<dev-instance>/agents`). Added in schema
60    /// v3 (cross-channel persistence P0.4): the registry is global, so a row
61    /// surfaced in a DIFFERENT channel must reconstruct its absolute
62    /// `working_directory` against the SOURCE base, not the reader's current
63    /// channel. `None` for legacy (v1/v2) records — the reader then falls
64    /// back to its current channel agents dir, matching pre-P0.4 behavior.
65    /// Old binaries deserialize this as `None`.
66    #[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    /// Lowest envelope schema that can faithfully represent this payload.
76    /// Climbs only when a higher-version-only field is populated, so an
77    /// older binary keeps reading records that don't use any newer feature —
78    /// only records that actually need the newer schema are hidden from it.
79    /// Writers should stamp the record with this rather than always using
80    /// `MAX_SUPPORTED_SCHEMA`.
81    ///
82    /// - v3 when `source_agents_base` is set (cross-channel reconstruction)
83    /// - v2 when `session_id` is set (cross-channel resume)
84    /// - v1 otherwise
85    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
115/// Per-row validation. Fails fast on anything that would let a
116/// malformed file be returned to the launch modal. Validation
117/// failures are skipped (not auto-fixed), logged, and the file stays
118/// on disk for ops triage.
119pub 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        // A v2 record validates under the bumped MAX_SUPPORTED_SCHEMA.
256        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        // v3 takes precedence even with session_id also set.
271        r.data.session_id = Some("sess-1".to_string());
272        assert_eq!(r.data.min_schema_version(), 3);
273        // Validates under the bumped MAX_SUPPORTED_SCHEMA.
274        r.schema_version = 3;
275        validate("abc", &r).unwrap();
276    }
277
278    #[test]
279    fn unknown_future_field_round_trips() {
280        // A v3 record written by a newer binary with an unknown field must
281        // still deserialize (serde ignores unknowns) so this reader can load
282        // it — the additive-evolution contract.
283        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}