Skip to main content

agentmux_common/
login_pty.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared pieces for running a provider CLI's login under a PTY and reading
5//! its OAuth URL out of the output: used by the desktop host
6//! (`agentmux-cef` `commands/cli_login.rs`) and by srv's `auth.*`
7//! (`agentmux-srv` `server/identity_auth_spawn.rs`,
8//! `identity/auth_patterns.rs`). docs/specs/SPEC_PROVIDER_LOGIN_THROUGH_SRV_2026_10_09.md.
9
10/// Columns for a login PTY. Wide enough that the CLI's own line-wrapping
11/// never wraps the OAuth URL. At 80 cols it did — Claude Code prints
12/// "If the browser didn't open, visit: <url>" as plain text and hard-wraps
13/// it mid-query-string, so only a truncated fragment missing `client_id`
14/// and everything after it was captured (issue #2429). 4096 comfortably
15/// covers any realistic query string.
16pub const LOGIN_PTY_COLS: u16 = 4096;
17
18/// Device Status Report cursor-position query — some CLIs (confirmed:
19/// Claude Code, on detecting a real TTY) send this immediately and BLOCK
20/// waiting for the terminal to reply with `ESC[<row>;<col>R` before
21/// printing anything else at all. A bare `portable_pty` handle has no
22/// attached terminal emulator to answer this automatically — nothing
23/// does, on any platform, without code specifically watching for it — so
24/// the child hangs forever and the whole capture loop below times out
25/// having seen zero bytes. This is the confirmed root cause of issue
26/// #2429 ("no PTY output captured... despite correct binary resolution"):
27/// isolated repro (a standalone portable_pty harness spawning the exact
28/// same binary/args) showed the child's first and ONLY output was these
29/// 4 bytes, then silence: answering this one query unblocked it
30/// immediately, and it printed its OAuth URL within half a second.
31pub const DSR_CURSOR_POSITION_QUERY: &[u8] = b"\x1b[6n";
32/// Synthetic "row 1, col 1" reply. The actual reported position doesn't
33/// matter to these CLIs — confirmed by the repro above — they only need
34/// *something* to answer so their TTY-capability probe stops blocking.
35pub const DSR_CURSOR_POSITION_REPLY: &[u8] = b"\x1b[1;1R";
36
37/// Wraps the raw PTY reader and transparently answers a cursor-position
38/// query (see [`DSR_CURSOR_POSITION_QUERY`]) the moment it appears
39/// anywhere in the stream — not just at startup, since a TUI could
40/// plausibly re-probe after a resize — passing every byte through
41/// unchanged to the caller (the query's own bytes included; they're
42/// harmless noise to the caller's line-scanning loop, not worth the extra
43/// complexity of stripping them from the pass-through).
44///
45/// Generic over `on_query` (rather than baking in `Arc<AppState>`
46/// directly) so the byte-scanning logic — the actually bug-prone part —
47/// is unit-testable without a PTY. A caller's closure writes
48/// [`DSR_CURSOR_POSITION_REPLY`] through the same writer that delivers a
49/// pasted OAuth code, since `portable_pty`'s writer is a single-owner handle.
50pub struct DsrRespondingReader<R, F> {
51    inner: R,
52    on_query: F,
53    /// Carry-over from the previous `read()` call — bounded to
54    /// `DSR_CURSOR_POSITION_QUERY.len() - 1` bytes — so a query split
55    /// across two `read()` calls (e.g. a slow/busy child) is still
56    /// detected instead of silently missed at the chunk boundary.
57    tail: Vec<u8>,
58}
59
60impl<R, F: FnMut()> DsrRespondingReader<R, F> {
61    pub fn new(inner: R, on_query: F) -> Self {
62        Self {
63            inner,
64            on_query,
65            tail: Vec::new(),
66        }
67    }
68}
69
70impl<R: std::io::Read, F: FnMut()> std::io::Read for DsrRespondingReader<R, F> {
71    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
72        let n = self.inner.read(buf)?;
73        if n == 0 {
74            return Ok(0);
75        }
76        self.tail.extend_from_slice(&buf[..n]);
77        if self
78            .tail
79            .windows(DSR_CURSOR_POSITION_QUERY.len())
80            .any(|w| w == DSR_CURSOR_POSITION_QUERY)
81        {
82            (self.on_query)();
83        }
84        let keep = DSR_CURSOR_POSITION_QUERY.len().saturating_sub(1);
85        if self.tail.len() > keep {
86            let drop = self.tail.len() - keep;
87            self.tail.drain(0..drop);
88        }
89        Ok(n)
90    }
91}
92
93/// A line of terminal output with its escape sequences removed: the visible
94/// `text`, and the target of every OSC-8 hyperlink it carried.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TerminalLine {
97    pub text: String,
98    pub link_uris: Vec<String>,
99}
100
101/// Remove terminal escape sequences from `line`. Two families matter here:
102///   * CSI  — `ESC [ … <final 0x40..=0x7e>` (colors, cursor moves)
103///   * OSC  — `ESC ] … (BEL | ST)` — an OSC-8 hyperlink embeds the URL in the
104///     sequence params AND repeats it as visible link text. A naive pass that
105///     only knew CSI left the raw `]8;;https://…<BEL>` in place and captured
106///     the URL twice (doubled) whenever OSC-8 IS present, so the OSC sequence
107///     is discarded but any URI it carried is kept in `link_uris`.
108pub fn strip_terminal_codes(line: &str) -> TerminalLine {
109    let mut clean = String::with_capacity(line.len());
110    let mut osc_uris: Vec<String> = Vec::new();
111    let bytes = line.as_bytes();
112    let mut i = 0;
113    while i < bytes.len() {
114        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
115            // CSI: ESC [ … <final byte in 0x40..=0x7e>
116            i += 2;
117            while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
118                i += 1;
119            }
120            i += 1; // consume the final byte
121        } else if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b']' {
122            // OSC: ESC ] … terminated by BEL (0x07) or ST (ESC \).
123            let seq_start = i + 2;
124            i = seq_start;
125            let mut seq_end = bytes.len();
126            while i < bytes.len() {
127                if bytes[i] == 0x07 {
128                    seq_end = i;
129                    i += 1;
130                    break;
131                }
132                if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
133                    seq_end = i;
134                    i += 2;
135                    break;
136                }
137                i += 1;
138            }
139            // OSC-8 hyperlink: "8;<params>;<URI>". Stash the URI as a fallback
140            // in case the visible link text isn't itself the URL.
141            if let Ok(seq) = std::str::from_utf8(&bytes[seq_start..seq_end]) {
142                if let Some(rest) = seq.strip_prefix("8;") {
143                    if let Some(uri) = rest.splitn(2, ';').nth(1) {
144                        if !uri.is_empty() {
145                            osc_uris.push(uri.to_string());
146                        }
147                    }
148                }
149            }
150        } else if bytes[i] == 0x1b {
151            // Lone / unrecognised ESC: drop the ESC byte.
152            i += 1;
153        } else {
154            // Copy the run up to the next ESC from the `&str` itself, so
155            // non-ASCII text (an email like josé@…) survives intact. ESC is
156            // ASCII, so both ends are char boundaries.
157            let end = bytes[i..].iter().position(|&b| b == 0x1b).map_or(bytes.len(), |p| i + p);
158            clean.push_str(&line[i..end]);
159            i = end;
160        }
161    }
162    TerminalLine {
163        text: clean,
164        link_uris: osc_uris,
165    }
166}
167
168/// Extract an OAuth URL from a line of CLI output: an `https://` URL that
169/// looks like an auth URL, from an OSC-8 link target first, else from the
170/// visible text.
171pub fn extract_auth_url(line: &str) -> Option<String> {
172    let TerminalLine {
173        text: clean,
174        link_uris: osc_uris,
175    } = strip_terminal_codes(line);
176    // Find https:// and extract until whitespace, a quote, or a stray BEL.
177    let pick = |s: &str| -> Option<String> {
178        let start = s.find("https://")?;
179        let rest = &s[start..];
180        let end = rest
181            .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '\u{7}')
182            .unwrap_or(rest.len());
183        let url = &rest[..end];
184        if url.contains("oauth") || url.contains("auth") || url.contains("login") {
185            Some(url.to_string())
186        } else {
187            None
188        }
189    };
190
191    // Prefer any OSC-8 URI: it's carried inside an escape-sequence payload,
192    // so it can never be truncated by the terminal's column-width wrapping.
193    // The visible text is only a fallback for CLIs that don't emit OSC-8 —
194    // it CAN be wrapped mid-URL by the PTY (see issue #2429 follow-up: the
195    // plain "If the browser didn't open, visit: ..." line got hard-wrapped
196    // at col 80, silently dropping `client_id` from the captured URL).
197    osc_uris
198        .iter()
199        .find_map(|u| pick(u))
200        .or_else(|| pick(&clean))
201}
202
203#[cfg(test)]
204mod dsr_responding_reader_tests {
205    use super::{DsrRespondingReader, DSR_CURSOR_POSITION_QUERY};
206    use std::cell::Cell;
207    use std::io::Read;
208
209    /// Yields each element of `chunks` on successive `read()` calls,
210    /// copying as much as fits in the caller's buffer — lets a test force
211    /// a specific byte-boundary split, which a plain `std::io::Cursor`
212    /// (single-buffer, fills as much as requested in one call) can't do.
213    struct ChunkedReader {
214        chunks: std::collections::VecDeque<Vec<u8>>,
215    }
216
217    impl ChunkedReader {
218        fn new(chunks: Vec<&[u8]>) -> Self {
219            Self {
220                chunks: chunks.into_iter().map(|c| c.to_vec()).collect(),
221            }
222        }
223    }
224
225    impl Read for ChunkedReader {
226        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
227            let Some(chunk) = self.chunks.front_mut() else {
228                return Ok(0);
229            };
230            let n = chunk.len().min(buf.len());
231            buf[..n].copy_from_slice(&chunk[..n]);
232            chunk.drain(..n);
233            if chunk.is_empty() {
234                self.chunks.pop_front();
235            }
236            Ok(n)
237        }
238    }
239
240    #[test]
241    fn invokes_callback_when_query_arrives_in_one_read() {
242        let inner = ChunkedReader::new(vec![DSR_CURSOR_POSITION_QUERY]);
243        let calls = Cell::new(0);
244        let mut r = DsrRespondingReader::new(inner, || calls.set(calls.get() + 1));
245        let mut buf = [0u8; 64];
246        let n = r.read(&mut buf).unwrap();
247        assert_eq!(&buf[..n], DSR_CURSOR_POSITION_QUERY);
248        assert_eq!(
249            calls.get(),
250            1,
251            "callback must fire exactly once for the query"
252        );
253    }
254
255    #[test]
256    fn does_not_invoke_callback_for_unrelated_bytes() {
257        let inner = ChunkedReader::new(vec![b"Opening browser to sign in...\r\n"]);
258        let calls = Cell::new(0);
259        let mut r = DsrRespondingReader::new(inner, || calls.set(calls.get() + 1));
260        let mut buf = [0u8; 64];
261        let n = r.read(&mut buf).unwrap();
262        assert_eq!(&buf[..n], b"Opening browser to sign in...\r\n");
263        assert_eq!(calls.get(), 0);
264    }
265
266    #[test]
267    fn detects_query_split_across_two_read_calls() {
268        // The exact failure mode a naive single-read scan would miss:
269        // "\x1b[6" in one chunk, "n" arriving in the next.
270        let inner = ChunkedReader::new(vec![b"\x1b[6", b"n"]);
271        let calls = Cell::new(0);
272        let mut r = DsrRespondingReader::new(inner, || calls.set(calls.get() + 1));
273        let mut buf = [0u8; 64];
274        let n1 = r.read(&mut buf).unwrap();
275        assert_eq!(&buf[..n1], b"\x1b[6");
276        assert_eq!(calls.get(), 0, "must not fire on the incomplete first half");
277        let n2 = r.read(&mut buf).unwrap();
278        assert_eq!(&buf[..n2], b"n");
279        assert_eq!(
280            calls.get(),
281            1,
282            "must fire once the second half completes the pattern"
283        );
284    }
285
286    #[test]
287    fn passes_every_byte_through_unchanged_query_included() {
288        // The query's own bytes are harmless noise to the line-scanning
289        // loop downstream — verify they're never stripped, only observed.
290        let payload = [DSR_CURSOR_POSITION_QUERY, b"Paste code here if prompted > "].concat();
291        let inner = ChunkedReader::new(vec![&payload]);
292        let mut r = DsrRespondingReader::new(inner, || {});
293        let mut out = Vec::new();
294        r.read_to_end(&mut out).unwrap();
295        assert_eq!(out, payload);
296    }
297
298    #[test]
299    fn does_not_refire_on_subsequent_unrelated_reads_after_the_query() {
300        let inner = ChunkedReader::new(vec![DSR_CURSOR_POSITION_QUERY, b"more output\r\n"]);
301        let calls = Cell::new(0);
302        let mut r = DsrRespondingReader::new(inner, || calls.set(calls.get() + 1));
303        let mut buf = [0u8; 64];
304        r.read(&mut buf).unwrap();
305        assert_eq!(calls.get(), 1);
306        r.read(&mut buf).unwrap();
307        assert_eq!(
308            calls.get(),
309            1,
310            "later unrelated bytes must not re-trigger the callback"
311        );
312    }
313}
314
315#[cfg(test)]
316mod extract_url_claude_authorize_tests {
317    use super::extract_auth_url as extract_url;
318
319    // Pins tier-1 URL capture for the revived in-app Claude login
320    // (SPEC_INAPP_CLAUDE_OAUTH_LOGIN_2026_08_03.md §2): the pinned CLI
321    // (2.1.198+) prints the PKCE authorize URL as a plain fallback line —
322    // and, in some renderings, OSC-8-hyperlink-wrapped. Both forms were
323    // observed in the 2026-08-03 live probes and both must yield the exact
324    // URL (not doubled, not truncated) or tier 1 silently falls back to the
325    // terminal tiers for a CLI that fully supports the in-app flow.
326    const AUTHORIZE_URL: &str = "https://claude.com/cai/oauth/authorize?code=true&client_id=abc-123&code_challenge=xyz_456&code_challenge_method=S256&state=st-789";
327
328    #[test]
329    fn captures_plain_fallback_line() {
330        let line = format!("If the browser didn't open, visit: {AUTHORIZE_URL}");
331        assert_eq!(extract_url(&line), Some(AUTHORIZE_URL.to_string()));
332    }
333
334    #[test]
335    fn captures_osc8_hyperlink_bel_terminated() {
336        // OSC-8 with the URL as both the sequence param and the visible link
337        // text (what the CLI actually emits) — the de-escaped visible text
338        // must win, single and intact.
339        let line = format!(
340            "If the browser didn't open, visit: \u{1b}]8;;{AUTHORIZE_URL}\u{7}{AUTHORIZE_URL}\u{1b}]8;;\u{7}"
341        );
342        assert_eq!(extract_url(&line), Some(AUTHORIZE_URL.to_string()));
343    }
344
345    #[test]
346    fn captures_osc8_hyperlink_st_terminated_with_non_url_link_text() {
347        // ST-terminated OSC-8 whose visible text is NOT the URL — the URI
348        // stashed from the escape sequence itself must be used as fallback.
349        let line = format!(
350            "Visit \u{1b}]8;;{AUTHORIZE_URL}\u{1b}\\this link\u{1b}]8;;\u{1b}\\ to sign in"
351        );
352        assert_eq!(extract_url(&line), Some(AUTHORIZE_URL.to_string()));
353    }
354
355    #[test]
356    fn captures_url_wrapped_in_csi_color_codes() {
357        let line = format!("\u{1b}[1m\u{1b}[36m{AUTHORIZE_URL}\u{1b}[0m");
358        assert_eq!(extract_url(&line), Some(AUTHORIZE_URL.to_string()));
359    }
360
361    #[test]
362    fn ignores_non_auth_urls() {
363        assert_eq!(extract_url("see https://claude.com/docs for details"), None);
364    }
365
366    #[test]
367    fn prefers_osc8_uri_when_the_visible_fallback_line_was_wrapped_by_the_pty() {
368        // Regression for the #2429 follow-up: at an 80-column PTY width, the
369        // CLI's own line-wrapping of the plain "visit: ..." text can split
370        // the URL mid-query-string before a `\r\n` is ever reached, so the
371        // OSC-8 payload (never subject to that wrapping) is the only place
372        // the full URL — with client_id intact — actually appears.
373        let truncated_visible = "https://claude.com/cai/oauth/authorize?code=t";
374        let line = format!(
375            "\u{1b}]8;;{AUTHORIZE_URL}\u{7}link text\u{1b}]8;;\u{7}If the browser didn't open, visit: {truncated_visible}"
376        );
377        assert_eq!(extract_url(&line), Some(AUTHORIZE_URL.to_string()));
378    }
379}
380
381#[cfg(test)]
382mod strip_terminal_codes_tests {
383    use super::strip_terminal_codes;
384
385    #[test]
386    fn keeps_non_ascii_text_intact() {
387        let t = strip_terminal_codes("Successfully logged in as josé@example.com");
388        assert_eq!(t.text, "Successfully logged in as josé@example.com");
389    }
390
391    #[test]
392    fn collects_osc8_targets_and_drops_the_sequence() {
393        let t = strip_terminal_codes("open ]8;;https://a.example/xhere]8;; now");
394        assert_eq!(t.text, "open here now");
395        assert_eq!(t.link_uris, vec!["https://a.example/x".to_string()]);
396    }
397}