1use http::HeaderMap;
2
3pub const CLAUDE_SESSION_HEADER: &str = "x-claude-code-session-id";
4pub const CLAUDE_AGENT_HEADER: &str = "x-claude-code-agent-id";
5pub const CLAUDE_PARENT_AGENT_HEADER: &str = "x-claude-code-parent-agent-id";
6
7const MAX_IDENTITY_LEN: usize = 512;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub enum ConversationIdentity {
11 Main(String),
12 Agent(String, String),
13}
14
15impl ConversationIdentity {
16 pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
17 let session = read_identity_header(headers, CLAUDE_SESSION_HEADER);
18 let agent = read_identity_header(headers, CLAUDE_AGENT_HEADER);
19 let parent = read_identity_header(headers, CLAUDE_PARENT_AGENT_HEADER);
20
21 if session.is_invalid() || agent.is_invalid() || parent.is_invalid() {
22 return None;
23 }
24
25 match (session.value(), agent.value(), parent.value()) {
26 (Some(session_id), Some(agent_id), _) => {
27 Some(Self::Agent(session_id.to_string(), agent_id.to_string()))
28 }
29 (Some(session_id), None, None) => Some(Self::Main(session_id.to_string())),
30 _ => None,
31 }
32 }
33}
34
35#[derive(Debug)]
36enum ParsedHeader<'a> {
37 Missing,
38 Valid(&'a str),
39 Invalid,
40}
41
42impl ParsedHeader<'_> {
43 fn value(&self) -> Option<&str> {
44 match self {
45 Self::Valid(value) => Some(value),
46 Self::Missing | Self::Invalid => None,
47 }
48 }
49
50 fn is_invalid(&self) -> bool {
51 matches!(self, Self::Invalid)
52 }
53}
54
55fn read_identity_header<'a>(headers: &'a HeaderMap, name: &str) -> ParsedHeader<'a> {
56 let mut values = headers.get_all(name).iter();
57 let Some(value) = values.next() else {
58 return ParsedHeader::Missing;
59 };
60 if values.next().is_some() {
61 return ParsedHeader::Invalid;
62 }
63
64 let Ok(value) = value.to_str() else {
65 return ParsedHeader::Invalid;
66 };
67 let value = value.trim_matches(|character| matches!(character, ' ' | '\t'));
68 if value.is_empty()
69 || value.len() > MAX_IDENTITY_LEN
70 || value.contains(',')
71 || !value.bytes().all(|byte| byte.is_ascii_graphic())
72 {
73 return ParsedHeader::Invalid;
74 }
75
76 ParsedHeader::Valid(value)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use http::{HeaderName, HeaderValue};
83
84 fn headers(values: &[(&str, &str)]) -> HeaderMap {
85 let mut headers = HeaderMap::new();
86 for (name, value) in values {
87 headers.append(
88 HeaderName::from_bytes(name.as_bytes()).unwrap(),
89 HeaderValue::from_str(value).unwrap(),
90 );
91 }
92 headers
93 }
94
95 #[test]
96 fn parses_main_agent_and_lineage_shapes() {
97 let cases = [
98 (
99 "main",
100 vec![(CLAUDE_SESSION_HEADER, "session-a")],
101 Some(ConversationIdentity::Main("session-a".to_string())),
102 ),
103 (
104 "direct agent without parent",
105 vec![
106 (CLAUDE_SESSION_HEADER, "session-a"),
107 (CLAUDE_AGENT_HEADER, "agent-a"),
108 ],
109 Some(ConversationIdentity::Agent(
110 "session-a".to_string(),
111 "agent-a".to_string(),
112 )),
113 ),
114 (
115 "nested direct child",
116 vec![
117 (CLAUDE_SESSION_HEADER, "session-a"),
118 (CLAUDE_AGENT_HEADER, "agent-child"),
119 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
120 ],
121 Some(ConversationIdentity::Agent(
122 "session-a".to_string(),
123 "agent-child".to_string(),
124 )),
125 ),
126 (
127 "sibling one",
128 vec![
129 (CLAUDE_SESSION_HEADER, "session-a"),
130 (CLAUDE_AGENT_HEADER, "agent-sibling-one"),
131 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
132 ],
133 Some(ConversationIdentity::Agent(
134 "session-a".to_string(),
135 "agent-sibling-one".to_string(),
136 )),
137 ),
138 (
139 "sibling two",
140 vec![
141 (CLAUDE_SESSION_HEADER, "session-a"),
142 (CLAUDE_AGENT_HEADER, "agent-sibling-two"),
143 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
144 ],
145 Some(ConversationIdentity::Agent(
146 "session-a".to_string(),
147 "agent-sibling-two".to_string(),
148 )),
149 ),
150 (
151 "same agent in another session",
152 vec![
153 (CLAUDE_SESSION_HEADER, "session-b"),
154 (CLAUDE_AGENT_HEADER, "agent-a"),
155 ],
156 Some(ConversationIdentity::Agent(
157 "session-b".to_string(),
158 "agent-a".to_string(),
159 )),
160 ),
161 (
162 "outer space and tab",
163 vec![
164 (CLAUDE_SESSION_HEADER, " \tsession-a\t "),
165 (CLAUDE_AGENT_HEADER, "\tagent-a "),
166 (CLAUDE_PARENT_AGENT_HEADER, " agent-parent\t"),
167 ],
168 Some(ConversationIdentity::Agent(
169 "session-a".to_string(),
170 "agent-a".to_string(),
171 )),
172 ),
173 ];
174
175 for (name, values, expected) in cases {
176 assert_eq!(
177 ConversationIdentity::from_headers(&headers(&values)),
178 expected,
179 "{name}"
180 );
181 }
182 }
183
184 #[test]
185 fn parent_is_validation_only_and_never_changes_the_owner() {
186 let direct = ConversationIdentity::from_headers(&headers(&[
187 (CLAUDE_SESSION_HEADER, "session-a"),
188 (CLAUDE_AGENT_HEADER, "agent-child"),
189 ]));
190 let nested = ConversationIdentity::from_headers(&headers(&[
191 (CLAUDE_SESSION_HEADER, "session-a"),
192 (CLAUDE_AGENT_HEADER, "agent-child"),
193 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
194 ]));
195 let reparented = ConversationIdentity::from_headers(&headers(&[
196 (CLAUDE_SESSION_HEADER, "session-a"),
197 (CLAUDE_AGENT_HEADER, "agent-child"),
198 (CLAUDE_PARENT_AGENT_HEADER, "another-parent"),
199 ]));
200
201 assert_eq!(direct, nested);
202 assert_eq!(nested, reparented);
203 }
204
205 #[test]
206 fn ambiguous_or_absent_tuples_are_stateless() {
207 let cases = [
208 ("all missing", vec![]),
209 (
210 "agent without session",
211 vec![(CLAUDE_AGENT_HEADER, "agent-a")],
212 ),
213 (
214 "parent without direct agent",
215 vec![
216 (CLAUDE_SESSION_HEADER, "session-a"),
217 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
218 ],
219 ),
220 (
221 "parent alone",
222 vec![(CLAUDE_PARENT_AGENT_HEADER, "agent-parent")],
223 ),
224 (
225 "agent and parent without session",
226 vec![
227 (CLAUDE_AGENT_HEADER, "agent-a"),
228 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
229 ],
230 ),
231 ];
232
233 for (name, values) in cases {
234 assert_eq!(
235 ConversationIdentity::from_headers(&headers(&values)),
236 None,
237 "{name}"
238 );
239 }
240 }
241
242 #[test]
243 fn rejects_malformed_text_in_every_identity_field() {
244 let malformed = [
245 ("empty", ""),
246 ("spaces only", " "),
247 ("tabs only", "\t\t"),
248 ("internal space", "two values"),
249 ("internal tab", "two\tvalues"),
250 ("leading comma", ",value"),
251 ("trailing comma", "value,"),
252 ("coalesced", "first, second"),
253 ("oversize", "oversize-placeholder"),
254 ];
255
256 for field in [
257 CLAUDE_SESSION_HEADER,
258 CLAUDE_AGENT_HEADER,
259 CLAUDE_PARENT_AGENT_HEADER,
260 ] {
261 for (shape, placeholder) in malformed {
262 let value = if shape == "oversize" {
263 "x".repeat(MAX_IDENTITY_LEN + 1)
264 } else {
265 placeholder.to_string()
266 };
267 let mut values = vec![
268 (CLAUDE_SESSION_HEADER, "session-a"),
269 (CLAUDE_AGENT_HEADER, "agent-a"),
270 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
271 ];
272 values
273 .iter_mut()
274 .find(|(name, _)| *name == field)
275 .unwrap()
276 .1 = &value;
277 assert_eq!(
278 ConversationIdentity::from_headers(&headers(&values)),
279 None,
280 "field={field} shape={shape}"
281 );
282 }
283 }
284 }
285
286 #[test]
287 fn rejects_duplicate_headers_in_every_identity_field() {
288 for field in [
289 CLAUDE_SESSION_HEADER,
290 CLAUDE_AGENT_HEADER,
291 CLAUDE_PARENT_AGENT_HEADER,
292 ] {
293 let mut values = vec![
294 (CLAUDE_SESSION_HEADER, "session-a"),
295 (CLAUDE_AGENT_HEADER, "agent-a"),
296 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
297 ];
298 values.push((field, "duplicate"));
299 assert_eq!(
300 ConversationIdentity::from_headers(&headers(&values)),
301 None,
302 "field={field}"
303 );
304 }
305 }
306
307 #[test]
308 fn rejects_nontext_headers_in_every_identity_field() {
309 for field in [
310 CLAUDE_SESSION_HEADER,
311 CLAUDE_AGENT_HEADER,
312 CLAUDE_PARENT_AGENT_HEADER,
313 ] {
314 let mut values = headers(&[
315 (CLAUDE_SESSION_HEADER, "session-a"),
316 (CLAUDE_AGENT_HEADER, "agent-a"),
317 (CLAUDE_PARENT_AGENT_HEADER, "agent-parent"),
318 ]);
319 values.insert(field, HeaderValue::from_bytes(&[0x80]).unwrap());
320 assert_eq!(
321 ConversationIdentity::from_headers(&values),
322 None,
323 "field={field}"
324 );
325 }
326 }
327
328 #[test]
329 fn malformed_agent_cannot_downgrade_a_valid_session_to_main() {
330 for malformed_agent in ["", "agent one", "agent-a,agent-b"] {
331 let identity = ConversationIdentity::from_headers(&headers(&[
332 (CLAUDE_SESSION_HEADER, "session-a"),
333 (CLAUDE_AGENT_HEADER, malformed_agent),
334 ]));
335 assert_eq!(identity, None, "agent={malformed_agent:?}");
336 }
337 }
338}