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("[32mSuccessfully logged in as josé@example.com[0m");
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}