codex_protocol/
agent_path.rs1use schemars::JsonSchema;
2use serde::Deserialize;
3use serde::Serialize;
4use std::fmt;
5use std::ops::Deref;
6use std::str::FromStr;
7use ts_rs::TS;
8
9#[derive(
10 Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, TS,
11)]
12#[serde(try_from = "String", into = "String")]
13#[schemars(with = "String")]
14#[ts(type = "string")]
15pub struct AgentPath(String);
16
17impl AgentPath {
18 pub const ROOT: &str = "/root";
19 pub const MORPHEUS: &str = "/morpheus";
20 const ROOT_SEGMENT: &str = "root";
21
22 pub fn root() -> Self {
23 Self(Self::ROOT.to_string())
24 }
25
26 pub fn morpheus() -> Self {
27 Self(Self::MORPHEUS.to_string())
28 }
29
30 pub fn from_string(path: String) -> Result<Self, String> {
31 validate_absolute_path(path.as_str())?;
32 Ok(Self(path))
33 }
34
35 pub fn as_str(&self) -> &str {
36 self.0.as_str()
37 }
38
39 pub fn is_root(&self) -> bool {
40 self.as_str() == Self::ROOT
41 }
42
43 pub fn name(&self) -> &str {
44 if self.is_root() {
45 return Self::ROOT_SEGMENT;
46 }
47 self.as_str()
48 .rsplit('/')
49 .next()
50 .filter(|segment| !segment.is_empty())
51 .unwrap_or(Self::ROOT_SEGMENT)
52 }
53
54 pub fn join(&self, agent_name: &str) -> Result<Self, String> {
55 validate_agent_name(agent_name)?;
56 Self::from_string(format!("{self}/{agent_name}"))
57 }
58
59 pub fn resolve(&self, reference: &str) -> Result<Self, String> {
60 if reference.is_empty() {
61 return Err("agent path must not be empty".to_string());
62 }
63 if reference == Self::ROOT {
64 return Ok(Self::root());
65 }
66 if reference.starts_with('/') {
67 return Self::try_from(reference);
68 }
69
70 validate_relative_reference(reference)?;
71 Self::from_string(format!("{self}/{reference}"))
72 }
73}
74
75impl TryFrom<String> for AgentPath {
76 type Error = String;
77
78 fn try_from(value: String) -> Result<Self, Self::Error> {
79 Self::from_string(value)
80 }
81}
82
83impl TryFrom<&str> for AgentPath {
84 type Error = String;
85
86 fn try_from(value: &str) -> Result<Self, Self::Error> {
87 Self::from_string(value.to_string())
88 }
89}
90
91impl From<AgentPath> for String {
92 fn from(value: AgentPath) -> Self {
93 value.0
94 }
95}
96
97impl FromStr for AgentPath {
98 type Err = String;
99
100 fn from_str(s: &str) -> Result<Self, Self::Err> {
101 Self::try_from(s)
102 }
103}
104
105impl AsRef<str> for AgentPath {
106 fn as_ref(&self) -> &str {
107 self.as_str()
108 }
109}
110
111impl Deref for AgentPath {
112 type Target = str;
113
114 fn deref(&self) -> &Self::Target {
115 self.as_str()
116 }
117}
118
119impl fmt::Display for AgentPath {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 f.write_str(self.as_str())
122 }
123}
124
125fn validate_agent_name(agent_name: &str) -> Result<(), String> {
126 if agent_name.is_empty() {
127 return Err("agent_name must not be empty".to_string());
128 }
129 if agent_name == AgentPath::ROOT_SEGMENT {
130 return Err("agent_name `root` is reserved".to_string());
131 }
132 if agent_name == "." || agent_name == ".." {
133 return Err(format!("agent_name `{agent_name}` is reserved"));
134 }
135 if agent_name.contains('/') {
136 return Err("agent_name must not contain `/`".to_string());
137 }
138 if !agent_name
139 .chars()
140 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
141 {
142 return Err(
143 "agent_name must use only lowercase letters, digits, and underscores".to_string(),
144 );
145 }
146 Ok(())
147}
148
149fn validate_absolute_path(path: &str) -> Result<(), String> {
150 if path == AgentPath::MORPHEUS {
151 return Ok(());
152 }
153
154 let Some(stripped) = path.strip_prefix('/') else {
155 return Err("absolute agent paths must start with `/root` or be `/morpheus`".to_string());
156 };
157 let mut segments = stripped.split('/');
158 let Some(root) = segments.next() else {
159 return Err("absolute agent path must not be empty".to_string());
160 };
161 if root != AgentPath::ROOT_SEGMENT {
162 return Err("absolute agent paths must start with `/root` or be `/morpheus`".to_string());
163 }
164 if stripped.ends_with('/') {
165 return Err("absolute agent path must not end with `/`".to_string());
166 }
167 for segment in segments {
168 validate_agent_name(segment)?;
169 }
170 Ok(())
171}
172
173fn validate_relative_reference(reference: &str) -> Result<(), String> {
174 if reference.ends_with('/') {
175 return Err("relative agent path must not end with `/`".to_string());
176 }
177 for segment in reference.split('/') {
178 validate_agent_name(segment)?;
179 }
180 Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::AgentPath;
186 use pretty_assertions::assert_eq;
187
188 #[test]
189 fn root_has_expected_name() {
190 let root = AgentPath::root();
191 assert_eq!(root.as_str(), AgentPath::ROOT);
192 assert_eq!(root.name(), "root");
193 assert!(root.is_root());
194 }
195
196 #[test]
197 fn morpheus_has_expected_name() {
198 let morpheus = AgentPath::morpheus();
199 assert_eq!(morpheus.as_str(), AgentPath::MORPHEUS);
200 assert_eq!(morpheus.name(), "morpheus");
201 assert!(!morpheus.is_root());
202 }
203
204 #[test]
205 fn join_builds_child_paths() {
206 let root = AgentPath::root();
207 let child = root.join("researcher").expect("child path");
208 assert_eq!(child.as_str(), "/root/researcher");
209 assert_eq!(child.name(), "researcher");
210 }
211
212 #[test]
213 fn resolve_supports_relative_and_absolute_references() {
214 let current = AgentPath::try_from("/root/researcher").expect("path");
215 assert_eq!(
216 current.resolve("worker").expect("relative path"),
217 AgentPath::try_from("/root/researcher/worker").expect("path")
218 );
219 assert_eq!(
220 current.resolve("/root/other").expect("absolute path"),
221 AgentPath::try_from("/root/other").expect("path")
222 );
223 }
224
225 #[test]
226 fn invalid_names_and_paths_are_rejected() {
227 assert_eq!(
228 AgentPath::root().join("BadName"),
229 Err("agent_name must use only lowercase letters, digits, and underscores".to_string())
230 );
231 assert_eq!(
232 AgentPath::try_from("/not-root"),
233 Err("absolute agent paths must start with `/root` or be `/morpheus`".to_string())
234 );
235 assert_eq!(
236 AgentPath::root().resolve("../sibling"),
237 Err("agent_name `..` is reserved".to_string())
238 );
239 }
240}