Skip to main content

gate4agent_adapters/
pty_identity.rs

1use gate4agent_types::{ProviderSessionIdentity, ProviderSessionKey};
2
3pub const KIMI_PTY_SESSION_ID_MAX_BYTES: usize = 256;
4const KIMI_PTY_IDENTITY_LINE_MAX_BYTES: usize = 1_024;
5const CODEX_PTY_SESSION_ID_BYTES: usize = 36;
6
7#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
8enum AnsiState {
9    #[default]
10    Ground,
11    Escape,
12    Csi,
13    Osc,
14    OscEscape,
15    ControlString,
16    ControlStringEscape,
17}
18
19/// Extracts the one explicit Kimi terminal identity label without treating
20/// arbitrary output, prompt echo, or an internal PTY UUID as provider truth.
21#[derive(Debug, Default)]
22pub struct KimiPtySessionIdentityExtractor {
23    ansi: AnsiState,
24    line: String,
25    overflowed: bool,
26    observed: Option<String>,
27}
28
29impl KimiPtySessionIdentityExtractor {
30    pub fn push(&mut self, chunk: &str) -> Option<ProviderSessionIdentity> {
31        if self.observed.is_some() {
32            return None;
33        }
34        for character in chunk.chars() {
35            if let Some(id) = self.push_character(character) {
36                self.observed = Some(id.clone());
37                return Some(ProviderSessionIdentity {
38                    key: ProviderSessionKey::SessionId,
39                    id,
40                    transcript_path: None,
41                });
42            }
43        }
44        None
45    }
46
47    /// A transport gap invalidates partial ANSI and line state, but an already
48    /// observed identity remains authoritative for the session lifetime.
49    pub fn reset_stream(&mut self) {
50        self.ansi = AnsiState::Ground;
51        self.line.clear();
52        self.overflowed = false;
53    }
54
55    /// Reads Kimi's own rendered welcome or `/status` panel. The status panel
56    /// intentionally uses `Session  session_*` without a colon and is accepted
57    /// only from a terminal screen snapshot, never from arbitrary raw output.
58    pub fn observe_screen(&mut self, screen: &str) -> Option<ProviderSessionIdentity> {
59        if self.observed.is_some() {
60            return None;
61        }
62        let id = screen
63            .lines()
64            .find_map(|line| explicit_kimi_session_id(line, true, true))?;
65        self.observed = Some(id.clone());
66        Some(ProviderSessionIdentity {
67            key: ProviderSessionKey::SessionId,
68            id,
69            transcript_path: None,
70        })
71    }
72
73    fn push_character(&mut self, character: char) -> Option<String> {
74        match self.ansi {
75            AnsiState::Ground => match character {
76                '\u{1b}' => self.ansi = AnsiState::Escape,
77                '\r' | '\n' => {
78                    let identity = (!self.overflowed)
79                        .then(|| explicit_kimi_session_id(&self.line, true, false))
80                        .flatten();
81                    self.line.clear();
82                    self.overflowed = false;
83                    return identity;
84                }
85                '\t' if !self.overflowed => {
86                    self.line.push('\t');
87                    return explicit_kimi_session_id(&self.line, false, false);
88                }
89                character if character.is_control() => {
90                    self.line.clear();
91                    self.overflowed = true;
92                }
93                character if !self.overflowed => {
94                    if self.line.len().saturating_add(character.len_utf8())
95                        > KIMI_PTY_IDENTITY_LINE_MAX_BYTES
96                    {
97                        self.line.clear();
98                        self.overflowed = true;
99                    } else {
100                        self.line.push(character);
101                        return explicit_kimi_session_id(&self.line, false, false);
102                    }
103                }
104                _ => {}
105            },
106            AnsiState::Escape => {
107                self.ansi = match character {
108                    '[' => AnsiState::Csi,
109                    ']' => AnsiState::Osc,
110                    'P' | '_' | '^' | 'X' => AnsiState::ControlString,
111                    _ => AnsiState::Ground,
112                };
113            }
114            AnsiState::Csi => {
115                if ('@'..='~').contains(&character) {
116                    self.ansi = AnsiState::Ground;
117                }
118            }
119            AnsiState::Osc => match character {
120                '\u{7}' => self.ansi = AnsiState::Ground,
121                '\u{1b}' => self.ansi = AnsiState::OscEscape,
122                _ => {}
123            },
124            AnsiState::OscEscape => {
125                self.ansi = if character == '\\' {
126                    AnsiState::Ground
127                } else {
128                    AnsiState::Osc
129                };
130            }
131            AnsiState::ControlString => {
132                if character == '\u{1b}' {
133                    self.ansi = AnsiState::ControlStringEscape;
134                }
135            }
136            AnsiState::ControlStringEscape => {
137                self.ansi = if character == '\\' {
138                    AnsiState::Ground
139                } else {
140                    AnsiState::ControlString
141                };
142            }
143        }
144        None
145    }
146}
147
148/// Extracts Codex's canonical thread UUID only from its rendered `/status`
149/// card. Raw PTY output is deliberately not accepted because prompt/model
150/// echoes can contain status-shaped text.
151#[derive(Debug, Default)]
152pub struct CodexPtySessionIdentityExtractor {
153    observed: Option<String>,
154}
155
156impl CodexPtySessionIdentityExtractor {
157    pub fn observe_screen(&mut self, screen: &str) -> Option<ProviderSessionIdentity> {
158        if self.observed.is_some() {
159            return None;
160        }
161        let id = screen.lines().find_map(explicit_codex_status_session_id)?;
162        self.observed = Some(id.clone());
163        Some(ProviderSessionIdentity {
164            key: ProviderSessionKey::SessionId,
165            id,
166            transcript_path: None,
167        })
168    }
169}
170
171fn explicit_codex_status_session_id(line: &str) -> Option<String> {
172    let line = line.trim_start_matches([' ', '\t']);
173    let border = line.chars().next()?;
174    if !matches!(border, '│' | '┃' | '║') {
175        return None;
176    }
177    let line = line[border.len_utf8()..].trim_start_matches([' ', '\t']);
178    let remainder = line.strip_prefix("Session:")?;
179    let whitespace = remainder
180        .chars()
181        .take_while(|character| matches!(character, ' ' | '\t'))
182        .count();
183    if whitespace < 3 {
184        return None;
185    }
186    let remainder = remainder.trim_start_matches([' ', '\t']);
187    if remainder.len() < CODEX_PTY_SESSION_ID_BYTES {
188        return None;
189    }
190    let (candidate, trailing) = remainder.split_at(CODEX_PTY_SESSION_ID_BYTES);
191    if !is_canonical_codex_thread_id(candidate) {
192        return None;
193    }
194    let trailing = trailing.trim_matches([' ', '\t']);
195    (trailing.chars().count() == 1 && trailing.starts_with(border))
196        .then(|| candidate.to_owned())
197}
198
199fn is_canonical_codex_thread_id(candidate: &str) -> bool {
200    candidate.len() == CODEX_PTY_SESSION_ID_BYTES
201        && candidate.bytes().enumerate().all(|(index, byte)| {
202            if matches!(index, 8 | 13 | 18 | 23) {
203                byte == b'-'
204            } else {
205                byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
206            }
207        })
208}
209
210fn explicit_kimi_session_id(
211    line: &str,
212    line_complete: bool,
213    allow_status_panel: bool,
214) -> Option<String> {
215    let mut line = line.trim_start_matches([' ', '\t']);
216    let framed = line.starts_with(['│', '┃', '║']);
217    if framed {
218        let border_bytes = line
219            .chars()
220            .next()
221            .expect("framed line has a leading border")
222            .len_utf8();
223        line = line[border_bytes..].trim_start_matches([' ', '\t']);
224    }
225    let (remainder, minimum_space) = match line.strip_prefix("Session:") {
226        Some(remainder) => (remainder, 1),
227        None if allow_status_panel => (line.strip_prefix("Session")?, 2),
228        None => return None,
229    };
230    let whitespace = remainder
231        .chars()
232        .take_while(|character| matches!(character, ' ' | '\t'))
233        .count();
234    if whitespace < minimum_space {
235        return None;
236    }
237    let remainder = remainder.trim_start_matches([' ', '\t']);
238    if !remainder.starts_with("session_") {
239        return None;
240    }
241    let token_bytes = remainder
242        .bytes()
243        .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
244        .count();
245    let token = &remainder[..token_bytes];
246    if token.len() <= "session_".len() || token.len() > KIMI_PTY_SESSION_ID_MAX_BYTES {
247        return None;
248    }
249    let trailing = &remainder[token_bytes..];
250    if trailing.is_empty() {
251        return line_complete.then(|| token.to_owned());
252    }
253    let trailing = trailing.trim_matches([' ', '\t']);
254    (trailing.is_empty()
255        || (framed && matches!(trailing, "│" | "┃" | "║")))
256        .then(|| token.to_owned())
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn kimi_identity_is_ansi_aware_and_chunk_safe() {
265        let mut extractor = KimiPtySessionIdentityExtractor::default();
266        assert_eq!(extractor.push("\u{1b}[2;"), None);
267        assert_eq!(extractor.push("1mSess"), None);
268        assert_eq!(extractor.push("ion:\u{1b}[0m sess"), None);
269        assert_eq!(extractor.push("ion_ab-19\r").unwrap().id, "session_ab-19");
270        assert_eq!(extractor.push("Session: session_other\n"), None);
271    }
272
273    #[test]
274    fn kimi_identity_rejects_echoes_unsafe_tokens_and_ansi_payloads() {
275        for output in [
276            "user echoed Session: session_injected\n",
277            "Session: session_bad/path\n",
278            "Session: session_bad:tail\n",
279            "Session: session_\n",
280            "Session: 7d8a6f4e\n",
281            "Session  session_status-only\n",
282            "\u{1b}]0;Session: session_title\u{7}ready\n",
283            "\u{1b}PSession: session_dcs\u{1b}\\ready\n",
284            "Sess\u{8}ion: session_backspace\n",
285        ] {
286            let mut extractor = KimiPtySessionIdentityExtractor::default();
287            assert_eq!(extractor.push(output), None, "{output:?}");
288        }
289    }
290
291    #[test]
292    fn kimi_status_screen_accepts_the_exact_colonless_framed_label() {
293        let mut extractor = KimiPtySessionIdentityExtractor::default();
294        let identity = extractor
295            .observe_screen(
296                "╭ Status ─────────────╮\n  │ Session       session_c63be266-18dc-45dc-8a5c-50831076c260 │\n  ╰──────────────────────╯",
297            )
298            .expect("Kimi status identity");
299        assert_eq!(
300            identity.id,
301            "session_c63be266-18dc-45dc-8a5c-50831076c260"
302        );
303        assert_eq!(extractor.push("Session: session_other\n"), None);
304    }
305
306    #[test]
307    fn stream_reset_never_joins_identity_across_a_transport_gap() {
308        let mut extractor = KimiPtySessionIdentityExtractor::default();
309        assert_eq!(extractor.push("Session: sess"), None);
310        extractor.reset_stream();
311        assert_eq!(extractor.push("ion_false\n"), None);
312        assert_eq!(
313            extractor.push("Session: session_true\n").unwrap().id,
314            "session_true"
315        );
316    }
317
318    #[test]
319    fn codex_status_screen_accepts_only_the_exact_framed_session_uuid() {
320        let mut extractor = CodexPtySessionIdentityExtractor::default();
321        let identity = extractor
322            .observe_screen(
323                "╭────────────────────────────────────────────╮\n│  Model:            gpt-5.4                   │\n│  Session:          0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │\n╰────────────────────────────────────────────╯",
324            )
325            .expect("Codex status identity");
326        assert_eq!(identity.key, ProviderSessionKey::SessionId);
327        assert_eq!(identity.id, "0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e");
328        assert_eq!(identity.transcript_path, None);
329        assert_eq!(
330            extractor.observe_screen(
331                "│  Session:          11111111-1111-4111-8111-111111111111 │"
332            ),
333            None
334        );
335    }
336
337    #[test]
338    fn codex_status_screen_rejects_unframed_echoes_and_noncanonical_ids() {
339        for screen in [
340            "Session:          0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e",
341            "│ user echoed Session:          0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │",
342            "│  Session           0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │",
343            "│  Session:  0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │",
344            "│  Session:          0F0F3C13-6CF9-4AA4-8B80-7D49C2F1BE2E │",
345            "│  Session:          0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e trailing │",
346            "│  Session:          0f0f3c13/6cf9-4aa4-8b80-7d49c2f1be2e │",
347        ] {
348            let mut extractor = CodexPtySessionIdentityExtractor::default();
349            assert_eq!(extractor.observe_screen(screen), None, "{screen:?}");
350        }
351    }
352}