use crate::error::{Error, Result};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, PtySize};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthStatus {
pub logged_in: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscription_type: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
pub fn auth_status() -> Result<AuthStatus> {
auth_status_with_binary("claude")
}
pub fn auth_status_with_binary(binary: &str) -> Result<AuthStatus> {
let out = std::process::Command::new(binary)
.args(["auth", "status", "--json"])
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => Error::BinaryNotFound {
name: binary.to_string(),
},
_ => Error::Io(e),
})?;
serde_json::from_slice(&out.stdout).map_err(Error::Json)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginMode {
SetupToken,
ClaudeAi,
Console,
}
impl LoginMode {
fn args(self) -> &'static [&'static str] {
match self {
LoginMode::SetupToken => &["setup-token"],
LoginMode::ClaudeAi => &["auth", "login", "--claudeai"],
LoginMode::Console => &["auth", "login", "--console"],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenSource {
Screen,
Osc52,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Osc52Status {
Absent,
Unterminated,
Undecodable,
PresentNoToken,
TokenRecovered,
}
#[derive(Debug, Clone)]
pub struct LoginOutcome {
pub token: Option<String>,
pub token_source: Option<TokenSource>,
pub credentials_updated: bool,
pub osc52: Osc52Status,
pub copy_nudge_sent: bool,
pub transcript: String,
}
impl From<&Osc52Scan> for Osc52Status {
fn from(scan: &Osc52Scan) -> Self {
match scan {
Osc52Scan::Absent => Osc52Status::Absent,
Osc52Scan::Unterminated => Osc52Status::Unterminated,
Osc52Scan::Undecodable => Osc52Status::Undecodable,
Osc52Scan::NoTokenInPayload => Osc52Status::PresentNoToken,
Osc52Scan::Token(_) => Osc52Status::TokenRecovered,
}
}
}
type OutBuf = Arc<(Mutex<(Vec<u8>, bool)>, Condvar)>;
pub struct LoginFlow {
child: Box<dyn portable_pty::Child + Send + Sync>,
killer: Box<dyn ChildKiller + Send + Sync>,
writer: Box<dyn std::io::Write + Send>,
_master: Box<dyn portable_pty::MasterPty + Send>,
buf: OutBuf,
mode: LoginMode,
finished: bool,
rejected: bool,
creds: CredsWatch,
}
#[derive(Debug, Clone)]
struct CredsWatch {
path: Option<std::path::PathBuf>,
baseline: Option<std::time::SystemTime>,
}
impl CredsWatch {
fn snapshot() -> Self {
let path = credentials_path();
let baseline = path.as_ref().and_then(|p| mtime(p));
Self { path, baseline }
}
fn updated(&self) -> bool {
let Some(path) = &self.path else { return false };
match (mtime(path), self.baseline) {
(Some(now), Some(then)) => now > then,
(Some(_), None) => true,
(None, _) => false,
}
}
}
fn credentials_path() -> Option<std::path::PathBuf> {
if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
return Some(std::path::PathBuf::from(dir).join(".credentials.json"));
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(std::path::PathBuf::from(home).join(".claude/.credentials.json"))
}
fn mtime(path: &std::path::Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
impl LoginFlow {
pub fn start(mode: LoginMode) -> Result<Self> {
Self::start_with_binary("claude", mode)
}
pub fn start_with_binary(binary: &str, mode: LoginMode) -> Result<Self> {
let creds = CredsWatch::snapshot();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: 40,
cols: 1000,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| Error::Unknown(format!("openpty failed: {e}")))?;
let mut cmd = CommandBuilder::new(binary);
cmd.args(mode.args());
cmd.env("TERM", "xterm-256color");
for var in [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
] {
cmd.env_remove(var);
}
if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd);
}
let child = pair.slave.spawn_command(cmd).map_err(|e| {
let msg = e.to_string();
if msg.contains("No such file") || msg.contains("not found") {
Error::BinaryNotFound {
name: binary.to_string(),
}
} else {
Error::Unknown(format!("spawn {binary} failed: {msg}"))
}
})?;
let killer = child.clone_killer();
let writer = pair
.master
.take_writer()
.map_err(|e| Error::Unknown(format!("pty writer: {e}")))?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| Error::Unknown(format!("pty reader: {e}")))?;
let buf: OutBuf = Arc::new((Mutex::new((Vec::new(), false)), Condvar::new()));
let buf_writer = Arc::clone(&buf);
std::thread::spawn(move || {
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => {
let (lock, cv) = &*buf_writer;
lock.lock().unwrap().0.extend_from_slice(&chunk[..n]);
cv.notify_all();
}
}
}
let (lock, cv) = &*buf_writer;
lock.lock().unwrap().1 = true;
cv.notify_all();
});
let portable_pty::PtyPair { master, slave } = pair;
drop(slave);
Ok(Self {
child,
killer,
writer,
_master: master,
buf,
mode,
finished: false,
rejected: false,
creds,
})
}
pub fn auth_url(&mut self, timeout: Duration) -> Result<String> {
self.auth_url_after(0, timeout)
}
pub fn retry_new_url(&mut self, timeout: Duration) -> Result<String> {
use std::io::Write;
if !self.rejected {
return Err(Error::InvalidState(
"retry_new_url is only valid after a CodeRejected outcome".to_string(),
));
}
let offset = {
let (lock, _) = &*self.buf;
lock.lock().unwrap().0.len()
};
self.writer.write_all(b"\r")?;
self.writer.flush()?;
let url = self.auth_url_after(offset, timeout)?;
self.rejected = false;
Ok(url)
}
fn auth_url_after(&mut self, offset: usize, timeout: Duration) -> Result<String> {
let deadline = Instant::now() + timeout;
let (lock, cv) = &*self.buf;
let mut guard = lock.lock().unwrap();
loop {
if let Some(url) = extract_auth_url(&guard.0[offset.min(guard.0.len())..]) {
return Ok(url);
}
if guard.1 {
let transcript = strip_ansi(&guard.0);
self.finished = true;
let code = reap_exit_code(&mut self.child);
return Err(Error::LoginChildExited {
code,
transcript: format!(
"[child=exited({code:?}) before printing an authorize URL]\n{transcript}"
),
});
}
let now = Instant::now();
if now >= deadline {
return Err(Error::Timeout);
}
let (g, _) = cv.wait_timeout(guard, deadline - now).unwrap();
guard = g;
}
}
pub fn submit_code(&mut self, code: &str) -> Result<()> {
use std::io::Write;
if self.rejected {
return Err(Error::InvalidState(
"previous code was rejected; call retry_new_url() to restart the CLI's \
OAuth flow (the old URL's PKCE challenge is dead), then submit the NEW code"
.to_string(),
));
}
self.writer.write_all(&prepare_code_paste(code)?)?;
self.writer.flush()?;
std::thread::sleep(SUBMIT_ENTER_DELAY);
self.writer.write_all(b"\r")?;
self.writer.flush()?;
Ok(())
}
pub fn submit_code_and_wait(&mut self, code: &str, timeout: Duration) -> Result<LoginOutcome> {
let offset = {
let (lock, _) = &*self.buf;
let len = lock.lock().unwrap().0.len();
len
};
if let Ok(Some(status)) = self.child.try_wait() {
self.finished = true;
let transcript = {
let (lock, _) = &*self.buf;
strip_ansi(&lock.lock().unwrap().0)
};
return Err(Error::LoginChildExited {
code: Some(status.exit_code()),
transcript: format!(
"[child=exited({}) BEFORE code submission — nothing was written]\n{transcript}",
status.exit_code()
),
});
}
let paste_mode = {
let (lock, _) = &*self.buf;
let g = lock.lock().unwrap();
paste_mode_at(&g.0, offset)
};
self.submit_code(code)?;
let deadline = Instant::now() + timeout;
let mut creds_seen_at: Option<Instant> = None;
let mut nudged = false;
let mut child_exit: Option<(u32, Instant)> = None;
const CREDS_TOKEN_GRACE: Duration = Duration::from_secs(3);
let (lock, cv) = &*self.buf;
let mut guard = lock.lock().unwrap();
loop {
let stripped = strip_ansi(&guard.0);
let osc52 = extract_osc52_token(&guard.0);
let creds_updated = creds_seen_at.is_some() || self.creds.updated();
if let Some(token) = extract_token(&stripped) {
if token_wrap_suspect(&stripped) {
if let Osc52Scan::Token(token) = osc52 {
return Ok(LoginOutcome {
token: Some(token),
token_source: Some(TokenSource::Osc52),
credentials_updated: creds_updated,
osc52: Osc52Status::TokenRecovered,
copy_nudge_sent: nudged,
transcript: stripped,
});
}
self.finished = true;
let _ = self.killer.kill();
return Err(Error::Protocol(
"minted token appears display-wrapped in PTY output; cannot extract reliably"
.to_string(),
));
}
return Ok(LoginOutcome {
token: Some(token),
token_source: Some(TokenSource::Screen),
credentials_updated: creds_updated,
osc52: Osc52Status::from(&osc52),
copy_nudge_sent: nudged,
transcript: stripped,
});
}
if let Osc52Scan::Token(token) = &osc52 {
return Ok(LoginOutcome {
token: Some(token.clone()),
token_source: Some(TokenSource::Osc52),
credentials_updated: creds_updated,
osc52: Osc52Status::TokenRecovered,
copy_nudge_sent: nudged,
transcript: stripped,
});
}
let tail = strip_ansi(&guard.0[offset.min(guard.0.len())..]);
if let Some(message) = detect_oauth_error(&tail) {
self.rejected = true;
return Err(Error::CodeRejected { message });
}
if creds_updated {
let first_detection = creds_seen_at.is_none();
let seen = *creds_seen_at.get_or_insert_with(Instant::now);
if first_detection {
use std::io::Write;
nudged = self
.writer
.write_all(b"c")
.and_then(|()| self.writer.flush())
.is_ok();
}
if seen.elapsed() >= CREDS_TOKEN_GRACE {
return Ok(LoginOutcome {
token: None,
token_source: None,
credentials_updated: true,
osc52: Osc52Status::from(&osc52),
copy_nudge_sent: nudged,
transcript: stripped,
});
}
}
if child_exit.is_none() {
if let Ok(Some(status)) = self.child.try_wait() {
child_exit = Some((status.exit_code(), Instant::now()));
}
}
let exit_drained = child_exit
.map(|(_, at)| at.elapsed() > Duration::from_secs(1))
.unwrap_or(false);
if guard.1 || exit_drained {
let transcript = strip_ansi(&guard.0);
self.finished = true;
if creds_updated || self.creds.updated() {
return Ok(LoginOutcome {
token: None,
token_source: None,
credentials_updated: true,
osc52: Osc52Status::from(&osc52),
copy_nudge_sent: nudged,
transcript,
});
}
let code = child_exit
.map(|(c, _)| c)
.or_else(|| reap_exit_code(&mut self.child));
let tail = strip_ansi(&guard.0[offset.min(guard.0.len())..]);
return Err(Error::LoginChildExited {
code,
transcript: format!(
"[channels: screen=no-token osc52={:?} credentials=unchanged copy-nudge={} submit-path={SUBMIT_PATH} paste-mode@submit={paste_mode} child=exited({code:?})]\n{tail}",
Osc52Status::from(&osc52),
if nudged { "sent" } else { "not-sent" },
),
});
}
let now = Instant::now();
if now >= deadline {
let tail = strip_ansi(&guard.0[offset.min(guard.0.len())..]);
let mut start = tail.len().saturating_sub(2000);
while !tail.is_char_boundary(start) {
start += 1;
}
return Err(Error::LoginTimeout {
transcript: format!(
"[channels: screen=no-token osc52={:?} credentials={} copy-nudge={} submit-path={SUBMIT_PATH} paste-mode@submit={paste_mode} child=alive]\n{}",
Osc52Status::from(&osc52),
if creds_updated {
"updated"
} else {
"unchanged"
},
if nudged { "sent" } else { "not-sent" },
&tail[start..]
),
});
}
let wait = (deadline - now).min(Duration::from_millis(200));
let (g, _) = cv.wait_timeout(guard, wait).unwrap();
guard = g;
}
}
pub fn finish(mut self, timeout: Duration) -> Result<LoginOutcome> {
let deadline = Instant::now() + timeout;
loop {
match self.child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() >= deadline {
let _ = self.killer.kill();
self.finished = true;
return Err(Error::Timeout);
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
self.finished = true;
return Err(Error::Unknown(format!("wait on login child: {e}")));
}
}
}
self.finished = true;
let (lock, cv) = &*self.buf;
let mut guard = lock.lock().unwrap();
let drain_deadline = Instant::now() + Duration::from_secs(2);
while !guard.1 && Instant::now() < drain_deadline {
let (g, _) = cv.wait_timeout(guard, Duration::from_millis(100)).unwrap();
guard = g;
}
let transcript = strip_ansi(&guard.0);
let osc52 = extract_osc52_token(&guard.0);
drop(guard);
let credentials_updated = self.creds.updated();
let osc52_status = Osc52Status::from(&osc52);
let (token, token_source) = match (extract_token(&transcript), osc52) {
(Some(t), _) => (Some(t), Some(TokenSource::Screen)),
(None, Osc52Scan::Token(t)) => (Some(t), Some(TokenSource::Osc52)),
(None, _) => (None, None),
};
if self.mode == LoginMode::SetupToken && token.is_none() && !credentials_updated {
return Err(Error::Unknown(format!(
"setup-token completed without minting a token; output:\n{transcript}"
)));
}
Ok(LoginOutcome {
token,
token_source,
credentials_updated,
osc52: osc52_status,
copy_nudge_sent: false,
transcript,
})
}
}
impl Drop for LoginFlow {
fn drop(&mut self) {
if !self.finished {
#[cfg(feature = "log")]
log::warn!("LoginFlow dropped while unfinished — killing login child (SIGTERM)");
let _ = self.killer.kill();
}
}
}
fn extract_auth_url(raw: &[u8]) -> Option<String> {
let hay = String::from_utf8_lossy(raw);
let mut rest: &str = &hay;
while let Some(start) = rest.find("\x1b]8;") {
let after = &rest[start + 4..];
if let Some(sep) = after.find(';') {
let uri = &after[sep + 1..];
let end = uri.find(['\x1b', '\x07']).unwrap_or(uri.len());
let uri = &uri[..end];
if uri.contains("oauth/authorize") {
return Some(uri.to_string());
}
rest = &after[sep + 1..];
} else {
break;
}
}
let text = strip_ansi(raw);
let start = text.find("https://")?;
let url: String = text[start..]
.chars()
.take_while(|c| !c.is_whitespace() && *c != '"' && *c != ')')
.collect();
url.contains("oauth/authorize").then_some(url)
}
fn collapsed_lower(s: &str) -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.flat_map(|c| c.to_lowercase())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Osc52Scan {
Absent,
Unterminated,
Undecodable,
NoTokenInPayload,
Token(String),
}
fn extract_osc52_token(raw: &[u8]) -> Osc52Scan {
use base64::Engine as _;
let hay = String::from_utf8_lossy(raw);
let mut best = Osc52Scan::Absent;
let mut rest: &str = &hay;
while let Some(start) = rest.find("\x1b]52;") {
let body = &rest[start + 5..];
let Some(sep) = body.find(';') else {
return Osc52Scan::Unterminated;
};
let payload_and_more = &body[sep + 1..];
let Some(end) = payload_and_more.find(['\x07', '\x1b']) else {
return Osc52Scan::Unterminated;
};
let payload = payload_and_more[..end].trim();
let decoded = base64::engine::general_purpose::STANDARD
.decode(payload)
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload));
match decoded {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
if let Some(token) = extract_token(&text) {
return Osc52Scan::Token(token);
}
best = Osc52Scan::NoTokenInPayload;
}
Err(_) => {
if best == Osc52Scan::Absent {
best = Osc52Scan::Undecodable;
}
}
}
rest = &payload_and_more[end..];
}
best
}
pub const SUBMIT_PATH: &str =
"bracketed-paste+lone-cr-150ms+term-forced+env-scrubbed+exit-aware+paste-probe/v6";
const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(150);
fn paste_mode_at(raw: &[u8], upto: usize) -> &'static str {
let hay = String::from_utf8_lossy(&raw[..upto.min(raw.len())]);
match (hay.rfind("\x1b[?2004h"), hay.rfind("\x1b[?2004l")) {
(Some(h), Some(l)) if l > h => "off",
(Some(_), _) => "on",
(None, Some(_)) => "off",
(None, None) => "never-advertised",
}
}
fn reap_exit_code(child: &mut Box<dyn portable_pty::Child + Send + Sync>) -> Option<u32> {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status.exit_code()),
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(50));
}
_ => return None,
}
}
}
fn prepare_code_paste(code: &str) -> Result<Vec<u8>> {
let code = code.trim();
if code.is_empty() {
return Err(Error::Protocol(
"authorization code is empty after trimming; refusing to submit".to_string(),
));
}
let mut buf = Vec::with_capacity(code.len() + 12);
buf.extend_from_slice(b"\x1b[200~");
buf.extend_from_slice(code.as_bytes());
buf.extend_from_slice(b"\x1b[201~");
Ok(buf)
}
fn detect_oauth_error(stripped: &str) -> Option<String> {
let collapsed = collapsed_lower(stripped);
let anchor = if collapsed.contains("oautherror") {
"oauth"
} else if collapsed.contains("pressentertoretry") {
""
} else {
return None;
};
let start = if anchor.is_empty() {
0
} else {
stripped.to_lowercase().find(anchor).unwrap_or(0)
};
let message: String = stripped[start..]
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.take(240)
.collect();
Some(message.trim().to_string())
}
fn token_wrap_suspect(text: &str) -> bool {
let Some(start) = text.find("sk-ant-oat01-") else {
return false;
};
let rest = &text[start..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.unwrap_or(rest.len());
let after = &rest[end..];
let mut chars = after.chars();
matches!(
(chars.next(), chars.next()),
(Some('\n'), Some(c)) if c.is_ascii_alphanumeric() || c == '-' || c == '_'
)
}
fn extract_token(text: &str) -> Option<String> {
let start = text.find("sk-ant-oat01-")?;
let token: String = text[start..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
Some(token)
}
fn strip_ansi(raw: &[u8]) -> String {
let s = String::from_utf8_lossy(raw);
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\x1b' {
if c != '\r' {
out.push(c);
}
continue;
}
match chars.next() {
Some('[') => {
for f in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&f) {
break;
}
}
}
Some(']') => {
let mut prev = '\0';
for f in chars.by_ref() {
if f == '\x07' || (prev == '\x1b' && f == '\\') {
break;
}
prev = f;
}
}
Some('(') | Some(')') => {
chars.next();
}
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const CAPTURE: &str = "\x1b[38;5;153m\u{2733}\x1b[39m Opening browser to sign in\u{2026}\
\x1b]8;id=wq6tp2;https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a&state=o8LKRdriZ\x1b\\\
https://claude.com/cai/oauth/aut\nhorize?code=true&client_id=9d1c\x1b]8;;\x1b\\";
#[test]
fn url_lifted_from_osc8_hyperlink_unwrapped() {
let url = extract_auth_url(CAPTURE.as_bytes()).expect("url found");
assert_eq!(
url,
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a&state=o8LKRdriZ"
);
}
#[test]
fn url_fallback_from_plain_text() {
let plain = b"Browser didn't open? Use the url below to sign in:\n\
https://console.anthropic.com/oauth/authorize?code=true&state=abc\n";
let url = extract_auth_url(plain).expect("url found");
assert_eq!(
url,
"https://console.anthropic.com/oauth/authorize?code=true&state=abc"
);
}
#[test]
fn no_url_in_spinner_noise() {
assert_eq!(
extract_auth_url(b"\x1b[2K\x1b[1G\xe2\x9c\xa2 waiting"),
None
);
}
#[test]
fn token_extracted_from_transcript() {
let t = "Success! Your token:\n sk-ant-oat01-Ab3_x-Y9\nKeep it secret.";
assert_eq!(extract_token(t).as_deref(), Some("sk-ant-oat01-Ab3_x-Y9"));
}
#[test]
fn ansi_stripping_keeps_text() {
let raw = b"\x1b[1mBold\x1b[0m and \x1b]8;;https://x\x1b\\link\x1b]8;;\x1b\\ text";
assert_eq!(strip_ansi(raw), "Bold and link text");
}
#[test]
fn auth_status_parses_cli_shape() {
let json = r#"{
"loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "m@x.io", "orgId": "9185", "orgName": "org", "subscriptionType": "max"
}"#;
let s: AuthStatus = serde_json::from_str(json).unwrap();
assert!(s.logged_in);
assert_eq!(s.auth_method.as_deref(), Some("claude.ai"));
assert_eq!(s.subscription_type.as_deref(), Some("max"));
assert!(s.extra.is_empty());
}
#[test]
fn auth_status_logged_out_minimal() {
let s: AuthStatus = serde_json::from_str(r#"{"loggedIn": false}"#).unwrap();
assert!(!s.logged_in);
assert_eq!(s.auth_method, None);
}
#[test]
fn column_escape_prompt_strips_to_run_together_text() {
let raw = b"Paste\x1b[8Gcode\x1b[13Ghere\x1b[18Gif\x1b[21Gprompted\x1b[30G>";
let stripped = strip_ansi(raw);
assert_eq!(stripped, "Pastecodehereifprompted>");
assert!(!stripped.contains("Paste code here"));
assert!(collapsed_lower(&stripped).contains("pastecodehere"));
}
#[test]
fn oauth_error_detected_despite_run_together_rendering() {
let stripped =
"OAuth error: Invalidcode. Please makesure the fullcde wascopied\nPress Enter to retry.";
let msg = detect_oauth_error(stripped).expect("detected");
assert!(msg.starts_with("OAuth error"));
assert!(msg.contains("Press Enter to retry"));
assert!(detect_oauth_error("Welcometo Claude Codev2.1.220").is_none());
}
#[test]
fn wrapped_token_is_flagged_not_truncated() {
assert!(token_wrap_suspect("token: sk-ant-oat01-abc\ndef more"));
assert!(!token_wrap_suspect("token: sk-ant-oat01-abcdef\n(copied)"));
assert!(!token_wrap_suspect("token: sk-ant-oat01-abcdef done"));
assert_eq!(
extract_token("token: sk-ant-oat01-abcdef done").as_deref(),
Some("sk-ant-oat01-abcdef")
);
}
#[test]
fn rejection_detected_via_retry_prompt_when_wording_mangled() {
let stripped = "Requstfailed withstatus code 400PressEntertoretry.";
let msg = detect_oauth_error(stripped).expect("retry prompt anchors detection");
assert!(msg.contains("400"));
assert!(detect_oauth_error("Pastecodehereifprompted>").is_none());
}
#[test]
fn osc52_token_decoded_from_raw_bel_and_st_terminated() {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode("sk-ant-oat01-XyZ_9-ab");
let bel = format!("noise\x1b]52;c;{b64}\x07more");
assert_eq!(
extract_osc52_token(bel.as_bytes()),
Osc52Scan::Token("sk-ant-oat01-XyZ_9-ab".into())
);
let st = format!("\x1b]52;c;{b64}\x1b\\");
assert_eq!(
extract_osc52_token(st.as_bytes()),
Osc52Scan::Token("sk-ant-oat01-XyZ_9-ab".into())
);
}
#[test]
fn osc52_straddling_read_boundary_is_not_decoded_truncated() {
use base64::Engine as _;
let b64 =
base64::engine::general_purpose::STANDARD.encode("sk-ant-oat01-full-secret-value");
let full = format!("\x1b]52;c;{b64}\x07");
let partial = &full.as_bytes()[..full.len() - 8];
assert_eq!(extract_osc52_token(partial), Osc52Scan::Unterminated);
assert!(!strip_ansi(full.as_bytes()).contains("sk-ant"));
}
#[test]
fn osc52_url_copy_is_no_token_and_garbage_is_undecodable() {
use base64::Engine as _;
let url_b64 = base64::engine::general_purpose::STANDARD
.encode("https://claude.com/cai/oauth/authorize?code=true");
let stream = format!("\x1b]52;c;{url_b64}\x07");
assert_eq!(
extract_osc52_token(stream.as_bytes()),
Osc52Scan::NoTokenInPayload
);
assert_eq!(
extract_osc52_token(b"\x1b]52;c;!!!not-base64!!!\x07"),
Osc52Scan::Undecodable
);
assert_eq!(extract_osc52_token(b"plain output"), Osc52Scan::Absent);
}
#[test]
fn credentials_watch_detects_create_and_update() {
let dir = std::env::temp_dir().join(format!("creds-watch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(".credentials.json");
let _ = std::fs::remove_file(&file);
let watch = CredsWatch {
path: Some(file.clone()),
baseline: None,
};
assert!(!watch.updated());
std::fs::write(&file, "{}").unwrap();
assert!(watch.updated());
let watch = CredsWatch {
path: Some(file.clone()),
baseline: mtime(&file),
};
assert!(!watch.updated());
let newer = std::time::SystemTime::now() + Duration::from_secs(5);
let f = std::fs::File::options().write(true).open(&file).unwrap();
f.set_modified(newer).unwrap();
drop(f);
assert!(watch.updated());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn code_paste_is_bracketed_trimmed_and_cr_free() {
for len in [64usize, 92, 108, 120] {
let code: String = "x".repeat(len - 6) + "#state";
let frame = prepare_code_paste(&format!("{code}\n")).unwrap();
let mut expected = b"\x1b[200~".to_vec();
expected.extend_from_slice(code.as_bytes());
expected.extend_from_slice(b"\x1b[201~");
assert_eq!(frame, expected, "len {len}");
assert!(!frame.contains(&b'\r'), "len {len}: CR must travel alone");
}
assert_eq!(
prepare_code_paste(" abc \r\n").unwrap(),
b"\x1b[200~abc\x1b[201~"
);
for empty in ["", " ", "\n", "\r\n", "\t"] {
assert!(
prepare_code_paste(empty).is_err(),
"empty guard must fire for {empty:?}"
);
}
}
}