Skip to main content

claude_codex/
request_identity.rs

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}