//! Shared Claude Code authentication — one long-lived token for every project. //! //! ## Why //! //! Without this, every container is its own authentication island: each one //! needs `claude login`, each one opens a browser flow, each one stores its own //! credential in its own config volume. `claude setup-token` mints a single //! ~1-year OAuth token that Claude Code accepts via `CLAUDE_CODE_OAUTH_TOKEN`, //! so one authentication event can cover the whole fleet. //! //! ## How the token is obtained //! //! Observed directly against Claude Code 2.1.226, because the flow is not what //! the design assumed. `claude setup-token` prints an authorization URL whose //! `redirect_uri` is **Anthropic-hosted** //! (`https://platform.claude.com/oauth/code/callback`) — it does *not* start a //! loopback listener. After signing in, the user copies a code off that page //! and the CLI waits at a `Paste code here if prompted >` prompt on **stdin**. //! It then prints the token. //! //! Two consequences: //! //! * The flow needs a way to deliver the pasted code, hence //! [`submit_claude_token_code`] and the stdin channel below. Without it the //! command would simply sit at the prompt until it timed out. //! //! And the code can be *refused*. On a bad paste the CLI prints //! `OAuth error: Invalid code…` / `Press Enter to retry.` and then blocks on //! stdin waiting for that Enter — it does **not** exit. Nothing recognised //! that, so a rejected code used to wedge the flow until [`SETUP_TIMEOUT`] //! with the UI still claiming it was finishing. [`detect_code_rejection`] //! spots it, [`CODE_REJECTED_EVENT`] tells the user, and the Enter is sent //! for them so their next code has a prompt to land in — bounded by //! [`MAX_CODE_ATTEMPTS`], because a retry loop nobody can win is just the //! same hang with more steps. //! //! * The URL is emitted as an **OSC 8 hyperlink**, and the visible text of //! that hyperlink is sliced by the CLI into terminal-width pieces on //! separate lines. Measured against 2.1.226: the URL is 346 characters, and //! at 80 columns it arrives as five separate hyperlink emissions, each //! carrying the *complete* URL in its OSC 8 parameter and 80 characters of //! it as visible text. Scraping the visible text therefore yields a //! truncated URL that still parses, still points at claude.com, and still //! fails to authorise — the worst possible shape of wrong. The parameter is //! contiguous and authoritative, so [`AnsiStripper`] surfaces it and //! [`LINK_EVENT`] carries it to the UI, which allowlists it before it is //! shown or opened. //! * [`crate::auth_bridge`] is **not involved**. There is no container-local //! listener to reach, so there is nothing for it to bridge. //! //! An earlier version turned the bridge on for the duration "in case a //! future CLI version goes back to a loopback redirect", and turned it off //! again afterwards. That was wrong twice over. The bridge flag is //! *persisted* to `projects.json`, and the restore only ran if the future //! completed — so a force-quit, kill or panic inside the 15-minute //! [`SETUP_TIMEOUT`] left it latched on, and the app re-armed an //! unauthenticated loopback port mirror on every subsequent launch, for a //! project whose owner had never opted in. And it bought nothing: the //! speculative future it defended against would need code changes here //! anyway. The bridge remains available as the per-project setting it always //! was; this command does not touch it. //! //! ## Handling of the token itself //! //! The token never reaches the frontend. It is parsed out of the command's //! output, written straight to the OS keychain, and from then on only //! [`crate::docker::container`] reads it, to inject the env var. Everything //! streamed to the UI passes through [`SecretRedactor`] first, and no command //! here returns the token or accepts it as an argument. use std::sync::OnceLock; use std::time::Duration; use futures_util::StreamExt; use tauri::{AppHandle, Emitter, State}; use tokio::io::AsyncWriteExt; use tokio::sync::{mpsc, oneshot, Mutex}; use crate::docker::container::is_container_running; use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec}; use crate::storage::secure; use crate::AppState; /// Milestones in the acquisition flow. Payload `{ project_id, message }`, /// matching the `container-progress` convention. const PROGRESS_EVENT: &str = "claude-token-progress"; /// Redacted output from `claude setup-token`, so the UI can show the user the /// URL to visit. Payload `{ project_id, chunk }`. const OUTPUT_EVENT: &str = "claude-token-output"; /// A sign-in URL lifted from an OSC 8 hyperlink parameter, which is the only /// place the *whole* URL appears — see the module docs. Payload /// `{ project_id, url }`. /// /// This is a candidate, not a verdict: the payload is container output, so the /// frontend re-parses it and applies the `ANTHROPIC_SIGN_IN_HOSTS` allowlist /// before showing it and again before handing it to the OS opener. const LINK_EVENT: &str = "claude-token-link"; /// The CLI refused the submitted code and is waiting to be handed another. /// Payload `{ project_id, message, attempts_remaining }`. const CODE_REJECTED_EVENT: &str = "claude-token-code-rejected"; /// How long to wait for the whole flow. Generous: the user has to switch to a /// browser, sign in, and approve. Bounded so a wedged exec can't leak a task. const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60); /// Documented shape of a `setup-token` credential. const TOKEN_PREFIX: &str = "sk-ant-oat01-"; /// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be /// believed. Real `setup-token` credentials run to ~90 body characters. /// /// This must be close to that real length, not merely "longer than prose". /// The earlier value of 32 accepted a *fragment* of a token — which is what a /// line wrap produces if `stty cols 200` fails and the pty falls back to 80 /// columns, splitting the value across two lines. Two bad things follow from /// accepting one: /// /// * the fragment gets stored as if it were the credential, so every /// container authenticates with a token that cannot work, and the failure /// surfaces far from its cause; /// * [`SecretRedactor`] masks the leading fragment because it carries the /// `sk-ant-` marker, but the *tail* on the next line carries no marker and /// is emitted to the UI in clear. /// /// Set below the real length by enough margin to survive a modest change in /// token format, and far above any fragment an 80-column wrap can produce (the /// prefix alone eats 13 columns, so the longest possible first line fragment is /// ~67). Rejecting a real-but-shorter token is a loud, recoverable failure — /// "printed no recognisable token" — whereas accepting a fragment is silent. const MIN_TOKEN_BODY: usize = 80; /// Redaction is deliberately broader than extraction: anything shaped like an /// Anthropic credential is masked on its way to the UI, not just `oat01` ones. const SECRET_MARKER: &str = "sk-ant-"; const SECRET_PLACEHOLDER: &str = "sk-ant-"; const MIN_SECRET_BODY: usize = 8; /// Cap on how much text [`SecretRedactor`] will withhold waiting for a /// candidate secret to end. Past this, it is not a token — release it (still /// redacted) rather than swallow the UI's output. const MAX_HOLDBACK: usize = 4096; /// Cap on the retained transcript used for parsing. The token is printed at the /// end, and a re-rendering TUI can repaint many times, so keeping the tail is /// both sufficient and bounded. const MAX_TRANSCRIPT: usize = 256 * 1024; /// Characters that can appear in the body of an Anthropic credential. fn is_token_byte(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'-' || b == b'_' } /// How far into a line a break has to sit before it can be believed to be the /// terminal's right margin rather than the end of a line of prose. /// /// This is what makes reassembling a wrapped credential safe. Without it, /// "join a credential-shaped run to whatever starts the next line" would happily /// weld `sk-ant-oat01-…` in a usage example onto the first word below it and /// manufacture a token-length string out of two unrelated things — silently /// storing a credential that cannot work, which is the exact failure /// [`MIN_TOKEN_BODY`] exists to prevent. A hard wrap, by contrast, always breaks /// at the pty's width, and no pty this code can be handed is narrower than 40 /// columns (Docker's default is 80; [`SETUP_TOKEN_SCRIPT`] asks for 400). const MIN_WRAP_COLUMN: usize = 40; /// How many hard wraps one credential may be reassembled across. A ~103 /// character token needs one at 80 columns and two at 40; three is slack, and a /// bound at all stops a pathological input walking the whole transcript. const MAX_CREDENTIAL_WRAPS: usize = 3; /// A credential-shaped run of characters, possibly spanning hard wraps. struct CredentialRun { /// One past the last byte of the run, embedded line breaks included. end: usize, /// How many credential characters it holds, line breaks excluded. body_len: usize, /// The run ran into the end of the buffer, so more input could extend it. open: bool, } /// Walk a credential body forwards from `body_start`, stepping over the hard /// line breaks a pty inserts when the value is wider than the terminal. /// /// Wrapping is not hypothetical and it is not harmless. `stty cols` in /// [`SETUP_TOKEN_SCRIPT`] fails *silently* (`2>/dev/null || true`), and an /// 80-column fallback splits the ~103 character token across two lines. Before /// this, that produced two bad outcomes at once: the parser saw only a /// too-short fragment and the whole sign-in failed for no visible reason, while /// [`redact_complete`] masked the first line — which carries the `sk-ant-` /// marker — and printed the *second* line, the tail of a live credential, /// straight to the UI. Both halves are handled here so the two can never /// disagree about where a credential ends. /// /// The column test is measured from the last line break *in the buffer given*. /// For [`SecretRedactor`], text earlier on the same line may already have been /// emitted and drained, so the measured column can be shorter than the true one /// — which can only make the scan *refuse* a join it would otherwise make, /// never invent one. In practice it does not bite: the redactor withholds from /// the marker onwards, so the whole credential and any wrap inside it are /// together in the buffer by the time this runs. fn scan_credential_body(bytes: &[u8], body_start: usize) -> CredentialRun { let mut end = body_start; let mut body_len = 0usize; let mut wraps = 0usize; loop { while end < bytes.len() && is_token_byte(bytes[end]) { end += 1; body_len += 1; } // Ran out of input mid-run: the rest may be in the next chunk. if end >= bytes.len() { return CredentialRun { end, body_len, open: true }; } if bytes[end] != b'\n' || wraps >= MAX_CREDENTIAL_WRAPS { return CredentialRun { end, body_len, open: false }; } // A run already long enough to *be* a credential does not need // continuing, and continuing it is how the reassembly turns into a // fabrication machine: a repainting TUI prints `Your token: \n` // over and over, so the character after the break is very often another // token character belonging to the next frame entirely. Stopping here // means the only runs ever joined are the ones too short to stand alone // — which is exactly what a wrap produces. // // The cost is a token wrapped at a width between ~93 and ~102 columns, // where the first line would already clear this bar. No pty in this flow // is that size (Docker gives 80, [`SETUP_TOKEN_SCRIPT`] asks for 400), // and the outcome there is the pre-existing loud failure, not a wrong // credential. if body_len >= MIN_TOKEN_BODY { return CredentialRun { end, body_len, open: false }; } let line_start = bytes[..end] .iter() .rposition(|b| *b == b'\n') .map_or(0, |i| i + 1); if end - line_start < MIN_WRAP_COLUMN { return CredentialRun { end, body_len, open: false }; } // The break is at a plausible margin, so whether this is a wrap turns on // what follows it. A continuation resumes in column 0 with more // credential characters; anything else — a blank line, an indent, prose // — ends the run. if end + 1 >= bytes.len() { return CredentialRun { end, body_len, open: true }; } if !is_token_byte(bytes[end + 1]) { return CredentialRun { end, body_len, open: false }; } wraps += 1; end += 1; } } // ───────────────────────────────────────────────────────────────────────────── // Token extraction // ───────────────────────────────────────────────────────────────────────────── /// Pull the long-lived token out of `claude setup-token`'s output. /// /// Strict by construction, because the alternative to failing is storing /// garbage that silently breaks every container: /// * the value must carry the documented `sk-ant-oat01-` prefix; /// * the prefix must not be glued to the tail of a longer word; /// * at least [`MIN_TOKEN_BODY`] token characters must follow it. /// /// Those characters may be split across hard line breaks — see /// [`scan_credential_body`] — and the breaks are removed from the value. The /// length floor is applied to the *reassembled* body, so a fragment is still /// never accepted on its own; reassembly only ever turns a failure into the /// whole credential, never a fragment into a plausible one. /// /// The **last** match wins. The command narrates before it succeeds, and a TUI /// may repaint the same frame repeatedly, so earlier matches are either prose /// or superseded repaints of the same value. pub fn parse_setup_token(output: &str) -> Option { let bytes = output.as_bytes(); let mut found = None; let mut cursor = 0usize; while let Some(offset) = output[cursor..].find(TOKEN_PREFIX) { let start = cursor + offset; cursor = start + TOKEN_PREFIX.len(); // `xsk-ant-oat01-…` is not a token, it is a substring of something else. if start > 0 && is_token_byte(bytes[start - 1]) { continue; } let run = scan_credential_body(bytes, start + TOKEN_PREFIX.len()); if run.body_len < MIN_TOKEN_BODY { continue; } // `run.end` lands on a non-token byte or the end of the buffer, and // every non-token byte is either ASCII or a UTF-8 lead byte, so both // ends are char boundaries. found = Some(output[start..run.end].replace('\n', "")); } found } // ───────────────────────────────────────────────────────────────────────────── // Redaction // ───────────────────────────────────────────────────────────────────────────── /// Mask every *complete* credential in `text`. /// /// A credential wrapped across lines is masked as one span, line breaks /// included, so the placeholder replaces the whole thing rather than leaving the /// tail visible on the next line. That welds the two display lines together; /// losing a line break in the transcript is a fair price for not printing half a /// live credential to the UI. fn redact_complete(text: &str) -> String { let bytes = text.as_bytes(); let mut out = String::with_capacity(text.len()); let mut copied = 0usize; let mut cursor = 0usize; while let Some(offset) = text[cursor..].find(SECRET_MARKER) { let start = cursor + offset; cursor = start + SECRET_MARKER.len(); if start > 0 && is_token_byte(bytes[start - 1]) { continue; } let run = scan_credential_body(bytes, start + SECRET_MARKER.len()); if run.body_len < MIN_SECRET_BODY { continue; } out.push_str(&text[copied..start]); out.push_str(SECRET_PLACEHOLDER); copied = run.end; cursor = run.end; } out.push_str(&text[copied..]); out } /// Where the tail that might still grow into a credential begins. Everything /// before this index is safe to emit; everything from it must be withheld until /// more input arrives. Returns `text.len()` when nothing needs withholding. fn holdback_index(text: &str) -> usize { let bytes = text.as_bytes(); // A credential already under way: the last marker whose body runs to the end // of what we have, so the next chunk could extend it. If the *last* marker // fails that test, no earlier one can pass it either — the disqualifying // character lies after them all. A body that stops at a hard wrap counts as // still open, because the continuation is what the next chunk will bring. if let Some(start) = text.rfind(SECRET_MARKER) { let clean_start = start == 0 || !is_token_byte(bytes[start - 1]); if clean_start && scan_credential_body(bytes, start + SECRET_MARKER.len()).open { return start; } } // Otherwise: a marker truncated mid-way by the chunk boundary. for len in (1..SECRET_MARKER.len()).rev() { if text.len() >= len && text.is_char_boundary(text.len() - len) && &text[text.len() - len..] == &SECRET_MARKER[..len] { return text.len() - len; } } text.len() } /// Masks credentials out of a stream, tolerating a secret split across chunk /// boundaries by withholding any tail that could still turn into one. #[derive(Default)] struct SecretRedactor { pending: String, } impl SecretRedactor { /// Absorb `chunk` and return the text that is now safe to show. fn push(&mut self, chunk: &str) -> String { self.pending.push_str(chunk); let mut split = holdback_index(&self.pending); if self.pending.len() - split > MAX_HOLDBACK { split = self.pending.len(); } let emit = redact_complete(&self.pending[..split]); self.pending.drain(..split); emit } /// Release whatever is still withheld. The stream is over, so a partial /// credential can no longer grow — but it is still redacted on the way out. fn flush(&mut self) -> String { let out = redact_complete(&self.pending); self.pending.clear(); out } } // ───────────────────────────────────────────────────────────────────────────── // Terminal control-sequence stripping // ───────────────────────────────────────────────────────────────────────────── /// Length in bytes of the UTF-8 character starting with `b`. fn utf8_len(b: u8) -> usize { if b < 0x80 { 1 } else if b >> 5 == 0b110 { 2 } else if b >> 4 == 0b1110 { 3 } else if b >> 3 == 0b11110 { 4 } else { 1 } } /// CSI final bytes that move the cursor. Claude Code's TUI lays text out by /// jumping to a column (`ESC [ 9 G`) instead of emitting spaces, so deleting /// these outright would weld neighbouring words together — which at best /// garbles the URL the user has to read, and at worst welds a preceding word /// onto the token and makes the parser reject it. They become a space instead: /// a separator can never fabricate or destroy a match. const CURSOR_MOVE_FINALS: &[u8] = b"ABCDEFGHd"; /// Escape intermediates that introduce a **three**-byte sequence: `ESC`, the /// intermediate, then one final byte. /// /// Claude Code prefixes every repaint frame with `ESC ( B` (designate ASCII as /// G0). Treating that as a two-byte escape — which is what "anything else is two /// bytes" did — consumed `ESC (` and emitted the `B` as ordinary text. Mostly /// that was a stray letter in the transcript; landing immediately before a /// token it would have glued `B` onto `sk-ant-oat01-…` and made /// [`parse_setup_token`] reject a perfectly good credential. const ESCAPE_INTERMEDIATES: &[u8] = b"()*+-./#%"; /// Largest OSC 8 target this will carry. Real authorize URLs are ~350 /// characters (measured: 346 against 2.1.226); anything past a few kilobytes is /// not a link, and the frontend's own relay cap is 8192. const MAX_LINK_LENGTH: usize = 8192; /// Cap on undrained OSC 8 targets, so a container printing hyperlinks in a loop /// cannot grow [`AnsiStripper`] without bound. The caller drains after every /// chunk, so reaching this means something pathological is happening. const MAX_PENDING_LINKS: usize = 32; /// Pull the link target out of an OSC 8 payload — everything between `ESC ]` /// and the terminator. /// /// Shape: `8;;`, e.g. `8;id=1umaq0e;https://claude.com/…`. The /// closing half of a hyperlink is `8;;` and so yields `None`, as does any other /// OSC (window title, the URL relay's own OSC 7777, …). fn osc8_target(payload: &[u8]) -> Option { let payload = std::str::from_utf8(payload).ok()?; let rest = payload.strip_prefix("8;")?; let uri = &rest[rest.find(';')? + 1..]; if uri.is_empty() || uri.len() > MAX_LINK_LENGTH { return None; } Some(uri.to_string()) } /// Strip terminal control sequences from the front of `bytes`, stopping at the /// first incomplete sequence or truncated character. Returns the clean text, any /// OSC 8 link targets found, and how many bytes were consumed. fn strip_ansi_prefix(bytes: &[u8]) -> (String, Vec, usize) { let mut out = String::with_capacity(bytes.len()); let mut links: Vec = Vec::new(); let mut i = 0usize; let consumed = 'scan: loop { if i >= bytes.len() { break 'scan i; } match bytes[i] { 0x1b => { if i + 1 >= bytes.len() { break 'scan i; } match bytes[i + 1] { // CSI: parameter/intermediate bytes, then a final 0x40..=0x7e. b'[' => { let mut j = i + 2; while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) { j += 1; } if j >= bytes.len() { break 'scan i; } if CURSOR_MOVE_FINALS.contains(&bytes[j]) { out.push(' '); } i = j + 1; } // OSC: runs until BEL or ST (ESC \). b']' => { let payload_start = i + 2; let mut j = payload_start; let payload_end; loop { if j >= bytes.len() { break 'scan i; } if bytes[j] == 0x07 { payload_end = j; j += 1; break; } if bytes[j] == 0x1b { if j + 1 >= bytes.len() { break 'scan i; } if bytes[j + 1] == b'\\' { payload_end = j; j += 2; break; } } j += 1; } // The one part of an OSC worth keeping: the hyperlink // target, which is the only contiguous copy of the // sign-in URL the CLI emits. if let Some(uri) = osc8_target(&bytes[payload_start..payload_end]) { links.push(uri); } i = j; } // Three-byte escapes: charset designation and friends. b if ESCAPE_INTERMEDIATES.contains(&b) => { if i + 2 >= bytes.len() { break 'scan i; } i += 3; } // Two-byte escapes (keypad mode, index, …). _ => i += 2, } } // A repaint returns to column 0. Turn that into a line break so the // old frame's trailing text cannot be glued onto the new frame's // leading text — which could otherwise fabricate a "token". A run of // CRs immediately before a LF is just the pty's ONLCR translation, // so it collapses into that single LF rather than blank lines. b'\r' => { let mut j = i; while j < bytes.len() && bytes[j] == b'\r' { j += 1; } if j >= bytes.len() { break 'scan i; } if bytes[j] != b'\n' { out.push('\n'); } i = j; } b'\n' => { out.push('\n'); i += 1; } b'\t' => { out.push('\t'); i += 1; } 0x00..=0x1f | 0x7f => i += 1, b => { let len = utf8_len(b); if i + len > bytes.len() { break 'scan i; } if let Ok(s) = std::str::from_utf8(&bytes[i..i + len]) { out.push_str(s); } i += len; } } }; (out, links, consumed) } /// Cap on the bytes [`AnsiStripper`] will hold waiting for a control sequence /// to terminate. /// /// [`strip_ansi_prefix`] stops at the first *incomplete* sequence and the /// remainder is carried to the next chunk — which is correct while the /// sequence really is going to end, and unbounded when it never does. A single /// `ESC ]` with no BEL and no ST swallows everything the container prints for /// the rest of the 15-minute [`SETUP_TIMEOUT`], and the buffer grows with it; /// `transcript` and [`SecretRedactor::pending`] are both already capped, so /// this was the one remaining way to make the flow eat memory. Generous enough /// that no legitimate sequence comes close (OSC 8 hyperlinks are the longest /// thing Claude Code emits, in the low hundreds of bytes). const MAX_ANSI_CARRY: usize = 64 * 1024; /// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete /// trailing sequence over to the next chunk. #[derive(Default)] struct AnsiStripper { carry: Vec, /// OSC 8 link targets seen since the last [`AnsiStripper::take_links`]. /// Kept out of the return value so every existing caller and test of /// `push` keeps reading as "bytes in, visible text out". links: Vec, } impl AnsiStripper { fn push(&mut self, chunk: &[u8]) -> String { self.carry.extend_from_slice(chunk); let (mut out, links, consumed) = strip_ansi_prefix(&self.carry); self.record_links(links); self.carry.drain(..consumed); // Past the cap the leading sequence is not going to terminate. Drop // its introducer and re-strip: the bytes behind it are then treated as // ordinary text rather than discarded, so a token hiding inside a // runaway OSC still reaches the parser. Dropping one byte per // overflowing chunk is enough — the cap can only be re-crossed by a // fresh chunk, which re-enters here. if self.carry.len() > MAX_ANSI_CARRY { log::warn!( "`claude setup-token` emitted an unterminated control sequence \ longer than {} bytes — treating it as text", MAX_ANSI_CARRY ); } while self.carry.len() > MAX_ANSI_CARRY { self.carry.drain(..1); let (more, links, consumed) = strip_ansi_prefix(&self.carry); self.record_links(links); self.carry.drain(..consumed); out.push_str(&more); } out } fn record_links(&mut self, links: Vec) { for link in links { if self.links.len() >= MAX_PENDING_LINKS { break; } self.links.push(link); } } /// Hand over the hyperlink targets seen so far and forget them. fn take_links(&mut self) -> Vec { std::mem::take(&mut self.links) } } /// Whether an OSC 8 target is worth forwarding to the UI as a sign-in candidate. /// /// Deliberately shallow. The frontend re-parses it, applies the /// `ANTHROPIC_SIGN_IN_HOSTS` allowlist before it is displayed, and applies it /// again at the sink before `openUrl` — that is where the security decision /// lives, and duplicating a host allowlist here would be a second place for it /// to go stale. All this does is keep obvious junk off the wire. fn usable_sign_in_link(uri: &str) -> bool { if !uri.starts_with("https://") && !uri.starts_with("http://") { return false; } if uri.len() > MAX_LINK_LENGTH { return false; } // Printable ASCII only. Control characters and whitespace are exactly how a // URL is smuggled past a display, and `new URL()` on the other side strips // some of them silently; a real authorize URL is percent-encoded anyway. if !uri.bytes().all(|b| (0x21..=0x7e).contains(&b)) { return false; } // This path bypasses [`SecretRedactor`] entirely, so nothing // credential-shaped is allowed to ride it. if uri.contains(SECRET_MARKER) { return false; } true } // ───────────────────────────────────────────────────────────────────────────── // Rejected codes // ───────────────────────────────────────────────────────────────────────────── /// How many codes may be refused before the flow gives up. /// /// The CLI will retry forever, which is the wrong bound for a dialog: the same /// truncated clipboard pasted a fourth time will fail a fourth time, and a flow /// that never resolves is indistinguishable from the hang this replaced. const MAX_CODE_ATTEMPTS: usize = 3; /// How much recent output to keep for [`detect_code_rejection`]. The message /// lands within a few hundred characters of the paste; anything older belongs to /// a previous attempt. const REJECTION_SCAN_WINDOW: usize = 4096; /// Phrases `claude setup-token` prints when it refuses a pasted code. /// /// Measured against 2.1.226 under a pty. The CLI writes /// /// ```text /// OAuth error: Invalid code. Please make sure the full code was copied /// Press Enter to retry. /// ``` /// /// on two lines placed with cursor motion rather than newlines, then blocks on /// stdin. Either phrase is enough — matching both would make a change to one of /// them silently restore the hang. const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"]; /// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail. fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) { buf.push_str(chunk); if buf.len() <= cap { return; } let cut = buf.len() - cap; let cut = (cut..buf.len()) .find(|i| buf.is_char_boundary(*i)) .unwrap_or(buf.len()); buf.drain(..cut); } /// Whether `text` shows the CLI has refused a code and parked on stdin. /// /// Whitespace is collapsed before matching because [`strip_ansi_prefix`] turns /// the cursor moves that lay this message out into spaces and line breaks, so /// the phrase arrives with runs of blanks inside it that are not in the source /// string. fn detect_code_rejection(text: &str) -> bool { let normalized: String = text .to_ascii_lowercase() .split_whitespace() .collect::>() .join(" "); CODE_REJECTED_MARKERS .iter() .any(|marker| normalized.contains(marker)) } // ───────────────────────────────────────────────────────────────────────────── // Commands // ───────────────────────────────────────────────────────────────────────────── /// Stdin of the acquisition currently in flight, so [`submit_claude_token_code`] /// can answer the CLI's `Paste code here` prompt. /// /// `Some` exactly while a flow is running, which doubles as the single-flight /// guard: the token is global, so two concurrent logins would race to overwrite /// each other's keychain entry and neither could tell which prompt it was /// feeding. static PENDING_INPUT: OnceLock>>>> = OnceLock::new(); fn pending_input() -> &'static Mutex>>> { PENDING_INPUT.get_or_init(|| Mutex::new(None)) } /// Abort channel for the in-flight flow, claimed and released in lockstep with /// [`PENDING_INPUT`]. /// /// Without this the only exits are "finished" and "timed out", so a user who /// closes the dialog would be locked out by the single-flight guard until /// `SETUP_TIMEOUT` elapsed. static CANCEL_TX: OnceLock>>> = OnceLock::new(); fn cancel_slot() -> &'static Mutex>> { CANCEL_TX.get_or_init(|| Mutex::new(None)) } fn emit_progress(app: &AppHandle, project_id: &str, message: &str) { let _ = app.emit( PROGRESS_EVENT, serde_json::json!({ "project_id": project_id, "message": message }), ); } fn emit_output(app: &AppHandle, project_id: &str, chunk: &str) { let _ = app.emit( OUTPUT_EVENT, serde_json::json!({ "project_id": project_id, "chunk": chunk }), ); } fn emit_link(app: &AppHandle, project_id: &str, url: &str) { let _ = app.emit( LINK_EVENT, serde_json::json!({ "project_id": project_id, "url": url }), ); } fn emit_code_rejected(app: &AppHandle, project_id: &str, message: &str, remaining: usize) { let _ = app.emit( CODE_REJECTED_EVENT, serde_json::json!({ "project_id": project_id, "message": message, "attempts_remaining": remaining, }), ); } /// Shell run inside the container. /// /// * `stty` widens the pty before Claude Code starts, so its layout engine does /// not wrap the token or the sign-in URL across lines. Docker's default exec /// pty is 80 columns; both are longer than that. Setting it here rather than /// via a post-start resize avoids racing the process's startup. /// /// 400 columns, not 200: the sign-in URL is 346 characters (measured against /// 2.1.226), so at 200 it wrapped anyway. Widening is *not* the fix for that /// — this line is `|| true` and fails silently, which is precisely how the /// truncated-URL bug survived — but it removes wrapping as a variable /// everywhere else in the flow. The two things that must survive a wrap /// regardless are handled directly: the URL comes from the OSC 8 parameter, /// and [`scan_credential_body`] reassembles a split token. /// * The `unset` line strips inherited auth so `setup-token` runs against a /// clean claude.ai login instead of warning about, or deferring to, whatever /// credential the container is already configured with — including a shared /// token from a previous run, which is likely the very thing being replaced. const SETUP_TOKEN_SCRIPT: &str = r#"stty cols 400 rows 50 2>/dev/null || true unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL \ ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK exec claude setup-token"#; /// Run `claude setup-token` in the container and return the token it printed. /// Streams redacted output as it arrives and forwards anything arriving on /// `input_rx` (the user's pasted code) to the command's stdin. async fn run_setup_token( app: &AppHandle, project_id: &str, container_id: &str, mut input_rx: mpsc::UnboundedReceiver>, mut cancel_rx: oneshot::Receiver<()>, ) -> Result { // A pty (`tty = true`) because `setup-token` renders an interactive TUI and // reads the pasted code in raw mode, which a plain pipe cannot provide. let AttachedExec { exec_id, mut output, mut input, } = create_attached_exec( container_id, vec![ "sh".to_string(), "-c".to_string(), SETUP_TOKEN_SCRIPT.to_string(), ], true, ) .await?; let mut stripper = AnsiStripper::default(); let mut redactor = SecretRedactor::default(); let mut transcript = String::new(); let deadline = tokio::time::Instant::now() + SETUP_TIMEOUT; // The last hyperlink forwarded, so the five consecutive emissions the CLI // makes for one wrapped URL become one event. Only the immediately previous // one is remembered — the CLI reprints the same URL after a retry, and a // genuinely different one would be news. let mut last_link: Option = None; // Recent visible output, scanned for a rejection message. Only filled while // a code is outstanding, so a repaint of an old message cannot re-fire. let mut recent = String::new(); let mut awaiting_code_result = false; let mut rejected_codes = 0usize; loop { // Writing stdin and reading stdout are driven from the same loop: with // a hijacked exec both halves ride one socket, and `input` must stay // alive for the whole session anyway — dropping it early would tear the // output stream down with it. let next = tokio::select! { // Cancellation wins the race so a user who gives up isn't held by // the single-flight guard until the timeout. Dropping `input` and // `output` on return tears the exec down with them. _ = &mut cancel_rx => { return Err( "Authentication cancelled. No token was stored.".to_string() ); } Some(data) = input_rx.recv() => { if let Err(e) = input.write_all(&data).await { return Err(format!( "Could not send the code to `claude setup-token`: {}. No token was stored.", e )); } let _ = input.flush().await; // Arm the rejection detector. Anything the CLI says from here // on is a verdict on *this* code. awaiting_code_result = true; recent.clear(); continue; } next = tokio::time::timeout_at(deadline, output.next()) => match next { Ok(next) => next, Err(_) => { return Err(format!( "Timed out after {} minutes waiting for `claude setup-token` to finish. \ No token was stored.", SETUP_TIMEOUT.as_secs() / 60 )) } }, }; let frame = match next { Some(Ok(frame)) => frame, Some(Err(e)) => { return Err(format!( "Lost the connection to `claude setup-token`: {}. No token was stored.", e )) } None => break, }; let visible = stripper.push(&frame.into_bytes()); // Before the emptiness check: a chunk can be nothing but hyperlink // wrappers, which is exactly the chunk carrying the sign-in URL. for link in stripper.take_links() { if !usable_sign_in_link(&link) || last_link.as_deref() == Some(link.as_str()) { continue; } emit_link(app, project_id, &link); last_link = Some(link); } if visible.is_empty() { continue; } // A refused code parks the CLI on stdin instead of ending it, so this is // the only thing between the user and a 15-minute wait. if awaiting_code_result { push_capped_tail(&mut recent, &visible, REJECTION_SCAN_WINDOW); if detect_code_rejection(&recent) { awaiting_code_result = false; recent.clear(); rejected_codes += 1; if rejected_codes >= MAX_CODE_ATTEMPTS { return Err(format!( "`claude setup-token` rejected the code {} times, so the sign-in was \ abandoned. No token was stored. The code is long and easy to \ truncate — copy all of it from the Anthropic page, then start \ authentication again.", rejected_codes )); } // The CLI is parked on "Press Enter to retry" and will not draw // its paste prompt again until it gets that Enter. Send it, so // the user's next code has somewhere to land. Bounded above, and // announced below — this is a retry the user is told about, not // a loop hidden from them. if let Err(e) = input.write_all(b"\r").await { return Err(format!( "`claude setup-token` rejected the code and could not be asked to \ retry: {}. No token was stored.", e )); } let _ = input.flush().await; let remaining = MAX_CODE_ATTEMPTS - rejected_codes; let message = format!( "That code was rejected — `claude setup-token` reports the full code was \ not copied. Copy it again from the Anthropic page and submit it; {} \ attempt{} left.", remaining, if remaining == 1 { "" } else { "s" } ); emit_code_rejected(app, project_id, &message, remaining); emit_progress(app, project_id, &message); } } transcript.push_str(&visible); if transcript.len() > MAX_TRANSCRIPT { // Keep the tail: that is where the token lands. let cut = transcript.len() - MAX_TRANSCRIPT / 2; let cut = (cut..transcript.len()) .find(|i| transcript.is_char_boundary(*i)) .unwrap_or(transcript.len()); transcript.drain(..cut); } let safe = redactor.push(&visible); if !safe.is_empty() { emit_output(app, project_id, &safe); } } let tail = redactor.flush(); if !tail.is_empty() { emit_output(app, project_id, &tail); } // `None` means the exit code could not be determined, not that it was zero. // Falling through to the token parse is right either way — a run that // printed a token succeeded whatever Docker says about it — but it is worth // saying so rather than silently calling it a clean exit. match wait_for_exec_exit(&exec_id).await { Some(0) => {} Some(code) => { return Err(format!( "`claude setup-token` exited with status {}. No token was stored — \ see the command output above for what went wrong.", code )) } None => log::warn!( "Could not read the exit status of `claude setup-token`; judging the run \ by whether it printed a token" ), } parse_setup_token(&transcript).ok_or_else(|| { if rejected_codes > 0 { format!( "`claude setup-token` ended without a token after rejecting {} code{}. \ Nothing was stored. Copy the whole code from the Anthropic page — it is \ long and easy to truncate — then start authentication again.", rejected_codes, if rejected_codes == 1 { "" } else { "s" } ) } else { "`claude setup-token` finished but printed no recognisable token. \ Nothing was stored. This usually means the login was cancelled, or the \ account has no Claude subscription (long-lived tokens require one)." .to_string() } }) } /// Mint a shared, long-lived Claude Code token by running `claude setup-token` /// inside `project_id`'s container, and store it in the OS keychain. /// /// The project only lends its container — a place to run the CLI that already /// has Claude Code installed. The resulting token is global, and is used by /// every Anthropic-backend project that has not opted out. #[tauri::command] pub async fn acquire_claude_token( project_id: String, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; let container_id = project.container_id.clone().ok_or_else(|| { format!( "Project '{}' has no container yet. Start it, then run authentication again.", project.name ) })?; if !is_container_running(&container_id).await.unwrap_or(false) { return Err(format!( "The container for '{}' is not running. Start it, then run authentication again.", project.name )); } // Claim the flow before touching anything else, so a second caller bounces // off the guard rather than half-configuring the same project. let (input_tx, input_rx) = mpsc::unbounded_channel::>(); let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); { // Both slots are claimed under the input lock held first, and released // in the same order below, so the guard and its abort channel can never // disagree about whether a flow is live. let mut slot = pending_input().lock().await; if slot.is_some() { return Err( "A Claude authentication flow is already running. Finish or cancel it first." .to_string(), ); } *slot = Some(input_tx); *cancel_slot().lock().await = Some(cancel_tx); } // No auth-bridge elevation here — see the module docs. `setup-token` has no // container-local callback to bridge, and the flag that would enable one is // *persisted*, so a crash anywhere inside the 15-minute timeout used to // leave an unauthenticated port mirror armed for good. emit_progress( &app_handle, &project_id, "Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.", ); let result = run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await; // Release the flow. Nothing else needs unwinding: this command changes no // persisted state until the token itself is stored, which is the point. *pending_input().lock().await = None; *cancel_slot().lock().await = None; let token = result?; secure::store_claude_oauth_token(&token)?; log::info!( "Stored a shared Claude authentication token (acquired via project {})", project_id ); emit_progress( &app_handle, &project_id, "Token stored in the OS keychain. Restart your Anthropic-backend containers to use it.", ); Ok(()) } /// Answer the `Paste code here if prompted >` prompt of a running /// [`acquire_claude_token`] with the code shown after signing in. /// /// Takes no project id: the flow is single-flight and the token is global, so /// there is only ever one prompt waiting. #[tauri::command] pub async fn submit_claude_token_code(code: String) -> Result<(), String> { let code = code.trim(); if code.is_empty() { return Err("Enter the code shown after signing in.".to_string()); } // The code goes to a TUI text input. A newline or escape embedded in it // would submit early or drive the widget, so reject control characters // outright rather than trying to sanitise them. if code.chars().any(char::is_control) { return Err("That code contains invalid characters. Copy it again and retry.".to_string()); } let slot = pending_input().lock().await; let sender = slot.as_ref().ok_or_else(|| { "No Claude authentication flow is waiting for a code. Start authentication first." .to_string() })?; let mut keystrokes = code.as_bytes().to_vec(); keystrokes.push(b'\r'); sender .send(keystrokes) .map_err(|_| "The authentication flow has already ended.".to_string()) } /// Abort an in-flight [`acquire_claude_token`]. /// /// Tears the `setup-token` exec down and releases the single-flight guard, so /// the user can immediately try again rather than waiting out `SETUP_TIMEOUT`. /// A no-op when nothing is running, so closing the dialog twice is harmless. #[tauri::command] pub async fn cancel_claude_token() -> Result<(), String> { let Some(sender) = cancel_slot().lock().await.take() else { return Ok(()); }; // `Err` only means the flow finished between the take and the send, which // is exactly the outcome cancelling wanted. let _ = sender.send(()); Ok(()) } /// Whether a shared Claude token exists. Deliberately a boolean — no command /// here ever hands the token itself to the frontend. #[tauri::command] pub async fn has_claude_token() -> Result { Ok(secure::has_claude_oauth_token()) } /// What [`clear_claude_token`] managed to reach. The keychain entry is always /// gone by the time this is returned — the rest is about copies of the token /// that live outside it. #[derive(Debug, Default, serde::Serialize)] pub struct ClearTokenOutcome { /// Snapshot images that were holding the token and have been rewritten. pub snapshots_scrubbed: Vec, /// Images still holding it, with the reason each could not be rewritten. /// Non-empty means the revocation is **incomplete** and the UI must say so. pub snapshots_failed: Vec, /// Rewritten, but the pre-rewrite image object could not be deleted because /// a container is still running off it. Worth mentioning, not worth /// alarming about — see `SnapshotScrubReport::superseded_retained`. pub snapshots_superseded: Vec, /// Set when Docker could not be reached at all, so nothing is known about /// what is still on disk. pub docker_unavailable: Option, } /// Forget the shared Claude token. /// /// Deleting the keychain entry is the easy half. The token also exists in two /// other places, and a "Revoke" button that leaves either of them behind is /// telling the user something untrue: /// /// * **Running containers** hold it in their environment. That resolves /// itself: the rotation id in `triple-c.claude-token-version` no longer /// matches, so the next start recreates the container and /// `MANAGED_AUTH_KEYS` blanks the variable. /// * **Snapshot images** hold it in `Config.Env`, and nothing resolves that on /// its own — the image outlives every container built from it, and /// `docker image inspect` will keep printing a live ~1-year credential for /// as long as the image exists. New commits no longer bake it in (see /// [`crate::docker::container::commit_container_snapshot`]), but images /// committed by earlier builds have to be rewritten, which is what /// [`scrub_secrets_from_snapshots`] does here. /// /// The keychain deletion is never rolled back if the scrub fails; a partially /// completed revocation is still better than none, and the outcome is reported /// so the UI can be explicit about what is left. #[tauri::command] pub async fn clear_claude_token() -> Result { secure::delete_claude_oauth_token()?; log::info!("Cleared the shared Claude authentication token"); let report = crate::docker::container::scrub_secrets_from_snapshots().await; if report.left_something_behind() { log::warn!( "Revoked the shared Claude token but {} snapshot image(s) may still contain it", report.failed.len() ); } Ok(ClearTokenOutcome { snapshots_scrubbed: report.scrubbed, snapshots_failed: report .failed .into_iter() .map(|(image, reason)| format!("{}: {}", image, reason)) .collect(), snapshots_superseded: report.superseded_retained, docker_unavailable: report.unavailable, }) } #[cfg(test)] mod tests { use super::*; /// A token-shaped value of realistic length. fn token(seed: char) -> String { format!("{}{}", TOKEN_PREFIX, std::iter::repeat(seed).take(90).collect::()) } #[test] fn extracts_the_token_from_realistic_output() { let tok = token('A'); let output = format!( "Claude Code long-lived token setup\n\ Opening browser to https://claude.ai/oauth/authorize?code=true\n\ Login successful!\n\n\ Your token:\n{}\n\n\ Set CLAUDE_CODE_OAUTH_TOKEN to this value.\n", tok ); assert_eq!(parse_setup_token(&output), Some(tok)); } #[test] fn ignores_prose_decoys_and_still_finds_the_real_token() { let tok = token('B'); let output = format!( "Set the env var like so:\n export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n\ or CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-\n\ Your token: {}\n", tok ); assert_eq!(parse_setup_token(&output), Some(tok)); } #[test] fn a_decoy_on_its_own_yields_nothing() { let output = "Usage: export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n"; assert_eq!(parse_setup_token(output), None); } #[test] fn no_match_returns_none_rather_than_guessing() { assert_eq!(parse_setup_token(""), None); assert_eq!( parse_setup_token("error: authentication cancelled by the user\n"), None ); // Right length, wrong product prefix. assert_eq!( parse_setup_token(&format!("sk-ant-api03-{}\n", "C".repeat(90))), None ); } #[test] fn multiple_matches_take_the_last() { let old = token('D'); let new = token('E'); let output = format!( "Replacing existing token {}\n...\nYour new token: {}\n", old, new ); assert_eq!(parse_setup_token(&output), Some(new)); } #[test] fn a_repainted_tui_frame_yields_the_same_token_once() { let tok = token('F'); // Same frame drawn three times, as a TUI would. let output = format!("Your token: {}\n", tok).repeat(3); assert_eq!(parse_setup_token(&output), Some(tok)); } #[test] fn a_prefix_glued_to_a_longer_word_is_not_a_token() { let output = format!("notasecretsk-ant-oat01-{}\n", "G".repeat(90)); assert_eq!(parse_setup_token(&output), None); } #[test] fn the_token_stops_at_the_first_non_token_character() { let tok = token('H'); let output = format!("token=\"{}\", expires=2027-08-09\n", tok); assert_eq!(parse_setup_token(&output), Some(tok)); } #[test] fn redaction_masks_a_token_in_one_piece() { let mut r = SecretRedactor::default(); let mut seen = r.push(&format!("Your token: {}\n", token('I'))); seen.push_str(&r.flush()); assert!(!seen.contains(TOKEN_PREFIX)); assert!(seen.contains(SECRET_PLACEHOLDER)); assert!(seen.contains("Your token: ")); } #[test] fn redaction_survives_a_token_split_across_chunks() { let tok = token('J'); let mut r = SecretRedactor::default(); let mut seen = String::new(); // Split mid-prefix and again mid-body — the worst case for a naive // per-chunk regex. seen.push_str(&r.push("Your token: sk-a")); seen.push_str(&r.push(&tok[4..40])); seen.push_str(&r.push(&tok[40..])); seen.push_str(&r.push("\ndone\n")); seen.push_str(&r.flush()); assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen); assert!(seen.contains(SECRET_PLACEHOLDER)); assert!(seen.ends_with("\ndone\n")); } #[test] fn redaction_leaves_ordinary_text_alone() { let mut r = SecretRedactor::default(); let mut seen = r.push("Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n"); seen.push_str(&r.flush()); assert_eq!( seen, "Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n" ); } #[test] fn ansi_stripping_recovers_the_token_from_a_styled_frame() { let tok = token('K'); let framed = format!( "\x1b[2J\x1b[H\x1b[1;36mYour token:\x1b[0m\r\n\x1b[32m{}\x1b[0m\r\n", tok ); let mut s = AnsiStripper::default(); let visible = s.push(framed.as_bytes()); assert!(!visible.contains('\x1b')); assert_eq!(parse_setup_token(&visible), Some(tok)); } /// Claude Code's TUI positions words with `ESC [ n G` instead of spaces /// (verified against 2.1.226). Deleting those would weld words together. #[test] fn ansi_stripping_turns_column_jumps_into_separators() { let mut s = AnsiStripper::default(); let visible = s.push(b"\x1b[38;2;215;119;87mWelcome\x1b[9Gto\x1b[12GClaude\x1b[19GCode\x1b[39m"); assert_eq!(visible, "Welcome to Claude Code"); } /// The failure this protects against: a column jump immediately before the /// token would, if simply deleted, glue the preceding word onto the prefix /// and make `parse_setup_token` reject a perfectly good token. #[test] fn a_column_jump_before_the_token_does_not_hide_it() { let tok = token('L'); let framed = format!("\x1b[2GToken\x1b[8G{}\r\n", tok); let mut s = AnsiStripper::default(); let visible = s.push(framed.as_bytes()); // The leading jump is an indent, so it becomes a space too. assert_eq!(visible, format!(" Token {}\n", tok)); assert_eq!(parse_setup_token(&visible), Some(tok)); } /// A pty with ONLCR emits `\r\r\n` at end of line; that is one break. #[test] fn carriage_return_runs_before_a_newline_collapse() { let mut s = AnsiStripper::default(); let visible = s.push(b"one\r\r\ntwo\r\r\n"); assert_eq!(visible, "one\ntwo\n"); } /// A bare CR is a repaint, and must still break the line so the old frame's /// tail cannot be welded onto the new frame's head. #[test] fn a_bare_carriage_return_breaks_the_line() { let mut s = AnsiStripper::default(); let visible = s.push(b"sk-ant-oat01-old\rsk-ant-oat01-new"); assert_eq!(visible, "sk-ant-oat01-old\nsk-ant-oat01-new"); } #[test] fn ansi_stripping_removes_osc8_hyperlink_wrappers() { let mut s = AnsiStripper::default(); let visible = s.push(b"\x1b]8;id=1;https://claude.com/x\x07https://claude.com/x\x1b]8;;\x07"); assert_eq!(visible, "https://claude.com/x"); } #[test] fn ansi_stripping_handles_a_sequence_split_across_chunks() { let mut s = AnsiStripper::default(); let mut visible = s.push(b"a\x1b[3"); visible.push_str(&s.push(b"1mb")); assert_eq!(visible, "ab"); } // ── Truncated and wrapped credentials ──────────────────────────────── // `stty cols 400` runs before Claude Code starts precisely so the ~103 // character token never wraps. But that line is `|| true` and fails // silently, and an 80-column fallback splits the value across two lines. // A fragment must never be accepted; the whole value, reassembled, must be. #[test] fn a_token_shorter_than_a_real_one_is_rejected() { let fragment = format!("{}{}", TOKEN_PREFIX, "M".repeat(MIN_TOKEN_BODY - 1)); assert_eq!( parse_setup_token(&format!("Your token: {}\n", fragment)), None ); } #[test] fn a_truncated_token_with_no_continuation_is_still_rejected() { let tok = token('N'); // The first half of an 80-column wrap, and nothing after it. let half = format!("Your token: {}\n", &tok[..68]); assert_eq!( parse_setup_token(&half), None, "half a credential must fail loudly, not be stored truncated" ); } #[test] fn a_line_wrapped_token_is_reassembled_whole() { let tok = token('N'); // Column 12 is where `Your token: ` ends, so an 80-column pty breaks // the value 68 characters in. let wrapped = format!("Your token: {}\n{}\n", &tok[..68], &tok[68..]); assert_eq!( parse_setup_token(&wrapped), Some(tok), "the halves of a wrapped token belong to one credential" ); } #[test] fn a_token_wrapped_twice_is_reassembled_whole() { let tok = token('N'); // A 40-column pty needs two breaks for a 103-character value. let wrapped = format!("{}\n{}\n{}\n", &tok[..40], &tok[40..80], &tok[80..]); assert_eq!(parse_setup_token(&wrapped), Some(tok)); } /// The guard that keeps reassembly from becoming a fabrication machine: a /// break early in a line is prose, not the terminal's right margin, so the /// two sides are two different things and must not be welded together. #[test] fn a_break_before_the_margin_does_not_join_two_lines() { let tail = "N".repeat(70); let text = format!("{}ABC\n{}\n", TOKEN_PREFIX, tail); assert_eq!( parse_setup_token(&text), None, "a short first line is prose; joining it would invent a credential" ); } /// A repainting TUI prints the same line again and again, so the character /// after a line break is very often the start of the next frame. Joining /// there would hand back `Your` instead of ``. #[test] fn a_repaint_after_a_complete_token_is_not_joined_onto_it() { let tok = token('P'); let output = format!("Your token: {}\n", tok).repeat(3); assert_eq!(parse_setup_token(&output), Some(tok)); } /// The wrap has to produce a *whole* credential to be believed. Two short /// pieces that still fall short of the floor are not one. #[test] fn joining_a_wrap_does_not_lower_the_length_floor() { let body_a = "N".repeat(45); let body_b = "N".repeat(20); let text = format!("{}{}\n{}\n", TOKEN_PREFIX, body_a, body_b); assert_eq!(parse_setup_token(&text), None); } /// The security half of the same bug. Before the parser could reassemble a /// wrapped token, the redactor could not either: it masked the first line, /// which carries the `sk-ant-` marker, and printed the second — the tail of /// a live credential — to the UI in clear. #[test] fn redaction_masks_both_halves_of_a_wrapped_token() { let tok = token('R'); let mut r = SecretRedactor::default(); let mut seen = r.push(&format!("Your token: {}\n{}\n", &tok[..68], &tok[68..])); seen.push_str(&r.flush()); assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen); assert!( !seen.contains(&tok[68..]), "the tail of the credential reached the UI: {}", seen ); assert!(seen.contains(SECRET_PLACEHOLDER)); } /// …and the same when the wrap lands on a chunk boundary, which is the way /// it actually arrives off the socket. #[test] fn redaction_masks_a_wrapped_token_split_across_chunks() { let tok = token('S'); let mut r = SecretRedactor::default(); let mut seen = r.push(&format!("Your token: {}", &tok[..68])); seen.push_str(&r.push("\n")); seen.push_str(&r.push(&tok[68..])); seen.push_str(&r.push("\ndone\n")); seen.push_str(&r.flush()); assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen); assert!(!seen.contains(&tok[68..]), "leaked tail: {}", seen); assert!(seen.ends_with("\ndone\n")); } #[test] fn a_real_length_token_is_still_accepted() { // Guards the floor from being raised past what Anthropic actually mints. let tok = token('O'); assert_eq!(tok.len() - TOKEN_PREFIX.len(), 90); assert!(90 > MIN_TOKEN_BODY); assert_eq!(parse_setup_token(&format!("{}\n", tok)), Some(tok)); } // ── OSC 8 sign-in link ─────────────────────────────────────────────── // The URL only exists in one piece inside the hyperlink parameter. Its // visible text is chopped into terminal-width slices, each of them a // separate, complete hyperlink emission — measured against 2.1.226 at 80 // columns, where a 346-character URL arrives as five of them. /// The real sign-in URL's shape and length, from the pty capture. fn sign_in_url() -> String { let url = format!( "https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-\ 5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2F\ oauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge={}&\ code_challenge_method=S256&state=su-{}", "R".repeat(43), "x".repeat(40) ); assert!(url.len() > 300, "the point of this fixture is its length"); url } /// One hyperlink emission: the whole URL in the parameter, `visible` on /// screen, then the empty `8;;` that closes it. fn osc8(url: &str, visible: &str) -> String { format!( "\x1b]8;id=1umaq0e;{}\x07\x1b[38;2;153;153;153m{}\x1b[39m\x1b]8;;\x07", url, visible ) } #[test] fn the_full_url_survives_a_display_text_wrapped_mid_url() { let url = sign_in_url(); let mut frame = String::new(); for slice in url.as_bytes().chunks(80) { frame.push_str(&osc8(&url, std::str::from_utf8(slice).unwrap())); frame.push_str("\r\r\n"); } let mut s = AnsiStripper::default(); let visible = s.push(frame.as_bytes()); let links = s.take_links(); // The visible text is exactly the broken form the old scraper saw. assert!(visible.contains(&format!("{}\n", &url[..80]))); assert!(!visible.contains(&url)); assert!(links.iter().all(|l| *l == url), "links: {:?}", links); assert_eq!(links.len(), 5, "one emission per display slice"); assert!(usable_sign_in_link(&links[0])); } #[test] fn the_closing_half_of_a_hyperlink_is_not_a_link() { let mut s = AnsiStripper::default(); s.push(b"\x1b]8;;\x07"); assert!(s.take_links().is_empty()); } #[test] fn a_non_hyperlink_osc_is_not_a_link() { let mut s = AnsiStripper::default(); // Window title, and the app's own URL relay sequence. s.push(b"\x1b]0;a title\x07\x1b]7777;open;aHR0cHM6Ly9ldmlsLnRsZA==\x07"); assert!(s.take_links().is_empty()); } #[test] fn a_hyperlink_split_across_chunks_still_yields_its_target() { let url = sign_in_url(); let frame = osc8(&url, &url[..80]); let (head, tail) = frame.as_bytes().split_at(60); let mut s = AnsiStripper::default(); s.push(head); assert!(s.take_links().is_empty(), "incomplete: nothing to report yet"); s.push(tail); assert_eq!(s.take_links().first().map(String::as_str), Some(url.as_str())); } #[test] fn only_plausible_http_targets_reach_the_frontend() { assert!(usable_sign_in_link("https://claude.ai/oauth/authorize?code=true")); assert!(usable_sign_in_link("http://127.0.0.1:8123/callback")); // The host allowlist lives on the frontend; these are the shallow // checks that keep junk off the wire. assert!(!usable_sign_in_link("file:///etc/passwd")); assert!(!usable_sign_in_link("javascript:alert(1)")); assert!(!usable_sign_in_link("https://claude.ai/a b")); assert!(!usable_sign_in_link("https://claude.ai/\u{7f}")); assert!(!usable_sign_in_link(&format!( "https://claude.ai/?t={}", "A".repeat(MAX_LINK_LENGTH) ))); // Nothing credential-shaped may ride the one path that skips redaction. assert!(!usable_sign_in_link(&format!( "https://claude.ai/?t={}", token('T') ))); } /// Claude Code prefixes every repaint frame with `ESC ( B`. Treating that /// as a two-byte escape emitted the `B` as text — and a stray `B` glued to /// the front of a token makes [`parse_setup_token`] refuse it. #[test] fn a_charset_designation_does_not_leave_a_letter_behind() { let tok = token('U'); let mut s = AnsiStripper::default(); let visible = s.push(format!("\x1b(B\x0f{}\r\n", tok).as_bytes()); assert_eq!(visible, format!("{}\n", tok)); assert_eq!(parse_setup_token(&visible), Some(tok)); } // ── Rejected codes ─────────────────────────────────────────────────── /// The exact bytes 2.1.226 emits after a bad paste, from the pty capture. const REJECTION_FRAME: &[u8] = b"\x1b(B\x0f\x1b[2K\x1b[1A\x1b[2K\x1b[G\x1b[1A\r\x1b[1C\x1b[4A\x1b[38;2;255;107;128m\ OAuth error: Invalid code. Please make sure the full code was copied\ \r\x1b[2B\x1b[39m\x1b[K\r\x1b[1B \x1b[38;2;177;185;249mPress \x1b[1mEnter\x1b[22m \ to retry.\x1b[39m\x1b[K\r\x1b[5A"; #[test] fn the_real_rejection_frame_is_recognised_after_ansi_stripping() { let mut s = AnsiStripper::default(); let visible = s.push(REJECTION_FRAME); assert!( detect_code_rejection(&visible), "a rejected code must be seen, not waited out: {:?}", visible ); } #[test] fn cursor_motion_inside_the_message_does_not_hide_it() { // The layout engine can place these words with column jumps, which // become runs of spaces. Matching must survive that. let mut s = AnsiStripper::default(); let visible = s.push(b"\x1b[2GPress\x1b[9GEnter\x1b[16Gto\x1b[20Gretry."); assert!(detect_code_rejection(&visible)); } #[test] fn ordinary_progress_output_is_not_mistaken_for_a_rejection() { assert!(!detect_code_rejection( "Browser didn't open? Use the url below to sign in (c to copy)" )); assert!(!detect_code_rejection("Paste code here if prompted >")); assert!(!detect_code_rejection("Login successful! Your token:")); assert!(!detect_code_rejection("")); } /// The retry budget has to be finite: the CLI itself will loop forever, and /// a flow that never resolves is the hang this replaced wearing a hat. #[test] fn the_retry_budget_is_bounded_and_leaves_room_for_a_retry() { assert!(MAX_CODE_ATTEMPTS >= 2, "one attempt is not a retry"); assert!(MAX_CODE_ATTEMPTS <= 5, "the budget must actually run out"); } // ── Bounded buffering ──────────────────────────────────────────────── #[test] fn an_unterminated_control_sequence_cannot_grow_the_carry_without_bound() { let mut s = AnsiStripper::default(); // An OSC introducer with no BEL and no ST. `strip_ansi_prefix` cannot // know it has ended, so every byte after it is carried — for the whole // 15-minute timeout, if nothing caps it. let mut seen = s.push(b"\x1b]8;id=1;"); for _ in 0..40 { seen.push_str(&s.push(&vec![b'A'; 4096])); } assert!( s.carry.len() <= MAX_ANSI_CARRY, "carry grew to {} bytes", s.carry.len() ); assert!(!seen.is_empty(), "the withheld text must be released, not dropped"); } #[test] fn a_token_printed_after_a_runaway_sequence_is_still_found() { let tok = token('Q'); let mut s = AnsiStripper::default(); let mut seen = s.push(b"\x1b]8;id=1;"); for _ in 0..40 { seen.push_str(&s.push(&vec![b'A'; 4096])); } seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes())); assert_eq!(parse_setup_token(&seen), Some(tok)); } }