From e7179dcef97f4ac95f20b80a31fbeb34b4982f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 20 Oct 2026 08:54:42 +0100 Subject: [PATCH] Recover expired local Claude logins with browser sign-in --- CHANGELOG.md | 2 + README.md | 3 +- src/app/render/conversation.rs | 69 ++++++++++++++++ src/app/sessions.rs | 73 +++++++++++++++++ src/claude_auth.rs | 141 +++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/session.rs | 32 +++++++- src/session/tests.rs | 28 +++++++ 8 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 src/claude_auth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fbee9..1bd90d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Notable changes to Agentaps are recorded here. ### Fixed +- Recover expired local Claude Code logins with browser sign-in and automatic session reconnection, while preserving remote-host guidance for SSH sessions. + - The website header shows the latest release and star count for `cachix/agentaps` in compact dark badges linking to the GitHub release and repository. ### Changed diff --git a/README.md b/README.md index a19cfbd..699d6f0 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ For local Claude ACP sessions, Agentaps uses the Claude Code CLI at `CLAUDE_CODE ## Current limitations - Web Connect shows the latest 100 messages per session and shortens long messages. It does not yet offer diff review or ACP form questions. -- URL based elicitation, ACP client file system and terminal methods, and built in authentication are not yet supported. Agents that require those client features may not work. +- Local Claude subscription sign-in is available from the session menu, or **Sign in to Claude** above the composer after a login expires. Complete sign-in in the browser; Agentaps reconnects the session so you can resend the request. SSH sessions still require sign-in on the remote host. +- URL based elicitation, ACP client file system and terminal methods, and general ACP authentication are not yet supported. Agents that require those client features may not work. - Session data is stored at `$XDG_CONFIG_HOME/agentaps/config.json`, or `~/.config/agentaps/config.json` when `XDG_CONFIG_HOME` is unset, on Linux and macOS. Windows uses its roaming application data directory (`%APPDATA%/agentaps/config.json`). Existing configurations migrate automatically. Closing the app can interrupt an active turn. ## License diff --git a/src/app/render/conversation.rs b/src/app/render/conversation.rs index 007d41f..3f956e4 100644 --- a/src/app/render/conversation.rs +++ b/src/app/render/conversation.rs @@ -153,6 +153,9 @@ impl Workspace { }), ); let location = project.display_path(); + let local_claude = project.ssh_host.is_none() + && known_agent(&agent.config.command) == Some(KnownAgent::Claude); + let can_sign_in = !agent.active_work && agent.login.is_none(); let can_reset = agent .context .is_some_and(|(used, size)| used > 0 && size > 0) @@ -227,7 +230,21 @@ impl Workspace { let reset_view = menu_view.clone(); let agent_view = menu_view.clone(); let archive_view = menu_view.clone(); + let sign_in_view = menu_view.clone(); let menu = menu + .when(local_claude, |menu| { + menu.item( + PopupMenuItem::new("Sign in to Claude…") + .disabled(!can_sign_in) + .on_click(move |_, _, cx| { + sign_in_view.update(cx, |this, cx| { + if this.activate_session_pane(pane_id, agent_id, cx) { + this.sign_in_to_claude(project_index, agent_index, cx); + } + }); + }), + ) + }) .item( PopupMenuItem::new("Rename session…").on_click(move |_, window, cx| { rename_view.update(cx, |this, cx| { @@ -451,6 +468,56 @@ impl Workspace { ), ), ); + if agent.auth_required || agent.login.is_some() { + let signing_in = agent.login.is_some(); + let remote = project.ssh_host.is_some(); + let login_url = agent.login.as_ref().and_then(|login| login.url.clone()); + chat = chat.child( + div() + .w_full() + .px_4() + .py_2() + .flex() + .items_center() + .gap_2() + .child( + Button::new(("claude-sign-in", agent_id)) + .label(if signing_in { + "Waiting for browser sign-in…" + } else { + "Sign in to Claude" + }) + .disabled(signing_in || remote || agent.active_work) + .tooltip(if remote { + "Sign in on the SSH host with claude auth login" + } else { + "Sign in to your Claude subscription in the browser" + }) + .on_click(self.pane_listener(cx, move |this, _, _, cx| { + this.sign_in_to_claude(project_index, agent_index, cx); + })), + ) + .when_some(login_url, |row, url| { + row.child( + Button::new(("claude-open-login", agent_id)) + .ghost() + .label("Open sign-in page") + .on_click(move |_, _, cx| cx.open_url(&url)), + ) + }) + .when(signing_in, |row| { + row.child( + Button::new(("claude-cancel-login", agent_id)) + .ghost() + .label("Cancel") + .on_click(self.pane_listener(cx, move |this, _, _, cx| { + this.projects[project_index].agents[agent_index].login = None; + cx.notify(); + })), + ) + }), + ); + } let file_results = self.file_results(cx); if self.file_mention_active(cx) { let mut menu = div() @@ -675,6 +742,8 @@ impl Workspace { }); let send_tooltip = format!("Send ({})", self.shortcut_label(Shortcut::Send)); let can_send = !is_recording + && !agent.auth_required + && agent.login.is_none() && (!draft_images.is_empty() || !draft_files.is_empty() || !self diff --git a/src/app/sessions.rs b/src/app/sessions.rs index 5882bd9..2c57e77 100644 --- a/src/app/sessions.rs +++ b/src/app/sessions.rs @@ -1,6 +1,78 @@ use super::*; impl Workspace { + pub(super) fn sign_in_to_claude( + &mut self, + project_index: usize, + agent_index: usize, + cx: &mut Context, + ) { + let project = &mut self.projects[project_index]; + let agent = &mut project.agents[agent_index]; + if project.ssh_host.is_some() || agent.login.is_some() || agent.active_work { + return; + } + if known_agent(&agent.config.command) != Some(KnownAgent::Claude) { + return; + } + let executable = std::env::var_os("CLAUDE_CODE_EXECUTABLE") + .map(PathBuf::from) + .or_else(|| self.claude_executable.as_ref().map(PathBuf::from)) + .or_else(|| crate::discovery::find_executable("claude")); + match executable + .ok_or_else(|| { + "Claude Code was not found. Set its executable in Settings > Agents.".into() + }) + .and_then(|executable| crate::claude_auth::Login::start(&executable, &project.path)) + { + Ok(login) => agent.login = Some(login), + Err(error) => agent.log(Role::System, error), + } + cx.notify(); + } + + fn poll_claude_login(&mut self, cx: &mut Context) -> bool { + let mut changed = false; + let mut reconnect = Vec::new(); + for (project_index, project) in self.projects.iter_mut().enumerate() { + for (agent_index, agent) in project.agents.iter_mut().enumerate() { + let Some(login) = agent.login.as_mut() else { + continue; + }; + let previous_url = login.url.clone(); + let result = login.poll(); + changed |= login.url != previous_url; + let Some(result) = result else { continue }; + changed = true; + agent.login = None; + match result { + Ok(()) => { + agent.auth_required = false; + agent.active_work = false; + agent.awaiting_response = false; + agent.config.was_working = false; + agent.config.active_prompt = None; + agent.oauth_retry = None; + agent.status = Status::Connecting; + agent.protocol = None; + agent.elicitations.clear(); + agent.permissions.clear(); + agent.log(Role::System, "Signed in to Claude. Reconnecting this session; resend your request when it is ready."); + reconnect.push((project_index, agent_index)); + } + Err(error) => agent.log(Role::System, error), + } + } + } + for (project_index, agent_index) in reconnect { + self.connect(project_index, agent_index); + } + if changed { + cx.notify(); + } + changed + } + pub(super) fn open_rename_session( &mut self, agent_id: u64, @@ -798,6 +870,7 @@ impl Workspace { } } } + changed |= self.poll_claude_login(cx); self.mark_displayed_agent_viewed(); for project in &mut self.projects { for agent in &mut project.agents { diff --git a/src/claude_auth.rs b/src/claude_auth.rs new file mode 100644 index 0000000..c6e2243 --- /dev/null +++ b/src/claude_auth.rs @@ -0,0 +1,141 @@ +use std::{ + io::{BufRead, BufReader}, + path::Path, + process::{Child, Command, Stdio}, + sync::mpsc::{self, Receiver}, + thread, + time::{Duration, Instant}, +}; + +/// Owns the browser login process so closing Agentaps also cancels sign-in. +pub(crate) struct Login { + child: Child, + urls: Receiver, + started: Instant, + pub(crate) url: Option, +} + +fn login_url(line: &str) -> Option { + line.split_whitespace().find_map(|word| { + let url = url::Url::parse(word).ok()?; + (url.scheme() == "https" + && matches!( + url.host_str(), + Some("claude.ai" | "console.anthropic.com" | "platform.claude.com") + ) + && url.path().starts_with("/oauth/authorize")) + .then(|| url.to_string()) + }) +} + +impl Login { + pub(crate) fn start(executable: &Path, cwd: &Path) -> Result { + let mut command = Command::new(executable); + command + .args(["auth", "login", "--claudeai"]) + .current_dir(cwd) + .env_remove("ANTHROPIC_API_KEY") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + let mut child = command + .spawn() + .map_err(|error| format!("Could not start Claude sign-in: {error}"))?; + let (tx, urls) = mpsc::channel(); + // Drain both streams, keeping only the browser URL. Never log credentials + // or persist the OAuth URL in conversation history. + let streams: Vec> = vec![ + Box::new(child.stdout.take().unwrap()), + Box::new(child.stderr.take().unwrap()), + ]; + for stream in streams { + let tx = tx.clone(); + thread::spawn(move || { + for line in BufReader::new(stream).lines().map_while(Result::ok) { + if let Some(url) = login_url(&line) { + let _ = tx.send(url); + } + } + }); + } + Ok(Self { + child, + urls, + started: Instant::now(), + url: None, + }) + } + + pub(crate) fn poll(&mut self) -> Option> { + for url in self.urls.try_iter() { + self.url = Some(url); + } + match self.child.try_wait() { + Ok(Some(status)) if status.success() => Some(Ok(())), + Ok(Some(_)) => Some(Err( + "Claude sign-in did not complete. Try again and finish the browser sign-in.".into(), + )), + Err(error) => Some(Err(format!("Could not check Claude sign-in: {error}"))), + Ok(None) if self.started.elapsed() >= Duration::from_secs(300) => Some(Err( + "Claude sign-in timed out. Try again and finish the browser sign-in within five minutes.".into(), + )), + Ok(None) => None, + } + } +} + +impl Drop for Login { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_claude_oauth_authorization_urls() { + assert_eq!( + login_url("Open https://claude.ai/oauth/authorize?state=example"), + Some("https://claude.ai/oauth/authorize?state=example".into()) + ); + for line in [ + "https://example.com/oauth/authorize", + "http://claude.ai/oauth/authorize", + "https://claude.ai.evil.example/oauth/authorize", + "https://claude.ai/docs", + "access_token=secret", + ] { + assert_eq!(login_url(line), None); + } + } + + #[cfg(unix)] + #[test] + fn background_login_reports_success_and_failure() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("claude"); + for (exit_code, success) in [(0, true), (1, false)] { + std::fs::write(&executable, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let mut login = Login::start(&executable, directory.path()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let result = loop { + if let Some(result) = login.poll() { + break result; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(result.is_ok(), success); + } + } +} diff --git a/src/main.rs b/src/main.rs index 52eee31..09c231d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod acp; mod app; mod appearance; mod audio; +mod claude_auth; mod cli; mod config; mod diff_view; diff --git a/src/session.rs b/src/session.rs index 2bd6287..61453fc 100644 --- a/src/session.rs +++ b/src/session.rs @@ -155,6 +155,8 @@ pub(crate) struct SessionController { pub(crate) restoring: Option, pub(crate) recovery_due: Option, pub(crate) oauth_retry: Option, + pub(crate) login: Option, + pub(crate) auth_required: bool, pub(crate) next_request_id: u64, pub(crate) messages: Vec, pub(crate) permissions: Vec, @@ -610,6 +612,8 @@ impl SessionController { restoring: None, recovery_due: None, oauth_retry: None, + login: None, + auth_required: false, next_request_id: 3, messages, permissions: Vec::new(), @@ -789,6 +793,9 @@ impl SessionController { } pub(crate) fn start_prompt(&mut self, prompt: Prompt) -> Result<(), String> { + if self.auth_required || self.login.is_some() { + return Err("Sign in to Claude before sending another request.".into()); + } if self.resetting_context { return Err("Agent is still connecting".into()); } @@ -868,6 +875,8 @@ impl SessionController { pub(crate) fn start_next_queued_prompt(&mut self) -> bool { if self.active_work + || self.auth_required + || self.login.is_some() || self.resetting_context || self.restoring.is_some() || self.config.was_working @@ -894,7 +903,28 @@ impl SessionController { } pub(crate) fn log(&mut self, role: Role, text: impl Into) { - self.push_message(role, text.into(), Vec::new()); + let text = text.into(); + let text = if role == Role::System + && crate::discovery::known_agent(&self.config.command) + == Some(crate::config::KnownAgent::Claude) + && text.contains("OAuth session expired and could not be refreshed") + { + self.auth_required = true; + self.finish_turn(Status::Error); + self.config.was_working = false; + self.recovery_due = None; + self.oauth_retry = None; + format!( + "Claude Code's login expired and could not be renewed. \ + Click Sign in to Claude above the composer and complete the browser sign-in. \ + Agentaps will reconnect this session; then resend your request. \ + For SSH projects, sign in on the remote host with `claude auth login`.\n\n\ + Original error: {text}" + ) + } else { + text + }; + self.push_message(role, text, Vec::new()); } fn push_message(&mut self, role: Role, text: String, images: Vec) { diff --git a/src/session/tests.rs b/src/session/tests.rs index cb51f2f..7768ea4 100644 --- a/src/session/tests.rs +++ b/src/session/tests.rs @@ -718,6 +718,34 @@ fn claude_refresh_lock_does_not_retry_after_agent_output() { assert!(agent.oauth_retry.as_ref().unwrap().due.is_none()); } +#[test] +fn claude_expired_login_stops_failed_turn_and_waits_for_sign_in() { + for protocol in [ProtocolVersion::V1, ProtocolVersion::V2] { + let mut agent = agent(protocol); + agent.config.command = vec!["claude-agent-acp".into()]; + agent.active_work = true; + agent.awaiting_response = true; + agent.config.was_working = true; + agent.config.active_prompt = Some("Review this PR".into()); + agent.config.pending_prompts.push("Next request".into()); + agent.handle_message(Path::new("/"), &json!({"id":3,"error":{ + "code":-32603, + "message":"Internal error: Failed to authenticate: OAuth session expired and could not be refreshed" + }})); + assert!(agent.auth_required); + assert!(!agent.active_work); + assert!(!agent.awaiting_response); + assert!(!agent.config.was_working); + assert!(agent.config.active_prompt.is_none()); + assert!(!agent.start_next_queued_prompt()); + assert_eq!(agent.config.pending_prompts.len(), 1); + assert!(agent.start_prompt("Try again".into()).is_err()); + let text = &agent.messages.last().unwrap().text; + assert!(text.contains("Click Sign in to Claude")); + assert!(text.contains("Original error: ACP error:")); + } +} + #[test] fn claude_refresh_lock_does_not_replay_accepted_v2_message() { let mut agent = agent(ProtocolVersion::V2);