From 5516ec459592f0f28f13aed64486e2fecd9be64e Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 31 Mar 2026 10:24:37 +0000 Subject: [PATCH] storkit: merge 448_story_send_oauth_login_link_via_chat_when_credentials_are_missing --- Cargo.lock | 97 ++++--------------- .../src/chat/transport/matrix/bot/messages.rs | 26 ++++- .../src/chat/transport/whatsapp/commands.rs | 26 ++++- server/src/llm/oauth.rs | 33 +++++++ server/src/llm/providers/claude_code.rs | 6 +- 5 files changed, 108 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e4b6bd4..e3000ccf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,7 +26,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "generic-array", ] @@ -286,15 +286,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block-padding" version = "0.3.3" @@ -436,7 +427,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", "zeroize", ] @@ -501,12 +492,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const_panic" version = "0.2.15" @@ -620,15 +605,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ctr" version = "0.9.2" @@ -647,7 +623,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", "rustc_version", "serde", @@ -773,7 +749,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "const-oid", "zeroize", ] @@ -846,22 +822,11 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -906,7 +871,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2 0.10.9", + "sha2", "subtle", "zeroize", ] @@ -1415,7 +1380,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1495,15 +1460,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a79f2aff40c18ab8615ddc5caa9eb5b96314aef18fe5823090f204ad988e813" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.8.1" @@ -2211,7 +2167,7 @@ dependencies = [ "serde", "serde_html_form", "serde_json", - "sha2 0.10.9", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", @@ -2304,7 +2260,7 @@ dependencies = [ "ruma", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "subtle", "thiserror 2.0.18", "time", @@ -2339,7 +2295,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", - "sha2 0.10.9", + "sha2", "thiserror 2.0.18", "tokio", "tracing", @@ -2393,7 +2349,7 @@ dependencies = [ "rmp-serde", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "thiserror 2.0.18", "zeroize", ] @@ -2652,7 +2608,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "sha2 0.10.9", + "sha2", "thiserror 1.0.69", "url", ] @@ -2710,7 +2666,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest 0.10.7", + "digest", "hmac", ] @@ -3557,7 +3513,7 @@ dependencies = [ "rand 0.8.5", "ruma-common", "serde_json", - "sha2 0.10.9", + "sha2", "thiserror 2.0.18", ] @@ -3605,7 +3561,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2 0.10.9", + "sha2", "walkdir", ] @@ -3930,7 +3886,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", + "digest", ] [[package]] @@ -3941,18 +3897,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "digest", ] [[package]] @@ -4110,7 +4055,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "serde_yaml", - "sha2 0.11.0", + "sha2", "strip-ansi-escapes", "tempfile", "tokio", @@ -4745,7 +4690,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] @@ -4845,7 +4790,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "sha2 0.10.9", + "sha2", "subtle", "thiserror 2.0.18", "x25519-dalek", diff --git a/server/src/chat/transport/matrix/bot/messages.rs b/server/src/chat/transport/matrix/bot/messages.rs index cc788c87..5192f1f0 100644 --- a/server/src/chat/transport/matrix/bot/messages.rs +++ b/server/src/chat/transport/matrix/bot/messages.rs @@ -616,7 +616,13 @@ pub(super) async fn handle_message( } Err(e) => { slog!("[matrix-bot] LLM error: {e}"); - let err_msg = format!("Error processing your request: {e}"); + let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) { + format!( + "Authentication required. [Click here to log in to Claude]({url})" + ) + } else { + format!("Error processing your request: {e}") + }; let _ = msg_tx.send(err_msg.clone()); (err_msg, None) } @@ -686,6 +692,24 @@ mod tests { assert_eq!(prompt, "@bob:example.com: What's up?"); } + // -- OAuth login link formatting ---------------------------------------- + + #[test] + fn oauth_error_produces_login_link() { + let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"; + let url = crate::llm::oauth::extract_login_url_from_error(err); + assert!(url.is_some(), "should extract URL from OAuth error"); + let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap()); + assert!(msg.contains("http://localhost:3001/oauth/authorize")); + assert!(msg.contains("[Click here to log in to Claude]")); + } + + #[test] + fn non_oauth_error_not_formatted_as_link() { + let err = "Some unrelated error"; + assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none()); + } + // -- bot_name / system prompt ------------------------------------------- #[test] diff --git a/server/src/chat/transport/whatsapp/commands.rs b/server/src/chat/transport/whatsapp/commands.rs index e0246649..d410b418 100644 --- a/server/src/chat/transport/whatsapp/commands.rs +++ b/server/src/chat/transport/whatsapp/commands.rs @@ -383,7 +383,13 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes } Err(e) => { slog!("[whatsapp] LLM error: {e}"); - let err_msg = format!("Error processing your request: {e}"); + let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) { + format!( + "Authentication required. Log in to Claude here: {url}" + ) + } else { + format!("Error processing your request: {e}") + }; let _ = msg_tx.send(err_msg.clone()); (err_msg, None) } @@ -491,6 +497,24 @@ mod tests { }) } + // ── OAuth login link formatting ─────────────────────────────────────── + + #[test] + fn whatsapp_oauth_error_produces_plain_text_url() { + let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"; + let url = crate::llm::oauth::extract_login_url_from_error(err); + assert!(url.is_some(), "should extract URL from OAuth error"); + let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap()); + assert!(msg.contains("http://localhost:3001/oauth/authorize")); + assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax"); + } + + #[test] + fn whatsapp_non_oauth_error_not_formatted_as_link() { + let err = "Some unrelated error occurred during processing"; + assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none()); + } + // ── Allowlist tests ─────────────────────────────────────────────────── #[tokio::test] diff --git a/server/src/llm/oauth.rs b/server/src/llm/oauth.rs index f986c863..ad039076 100644 --- a/server/src/llm/oauth.rs +++ b/server/src/llm/oauth.rs @@ -161,10 +161,43 @@ pub async fn refresh_access_token() -> Result<(), String> { Ok(()) } +/// Extract the OAuth login URL from an error message produced by the Claude Code provider. +/// +/// The provider returns errors like: +/// `"OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"` +/// +/// Returns the URL portion when the error indicates missing or expired credentials, +/// `None` otherwise. +pub fn extract_login_url_from_error(err: &str) -> Option<&str> { + let marker = "Please log in: "; + let start = err.find(marker)?; + Some(err[start + marker.len()..].trim()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn extract_login_url_from_oauth_error() { + let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"; + let url = extract_login_url_from_error(err); + assert_eq!(url, Some("http://localhost:3001/oauth/authorize")); + } + + #[test] + fn extract_login_url_returns_none_for_unrelated_error() { + let err = "Some other error occurred"; + assert!(extract_login_url_from_error(err).is_none()); + } + + #[test] + fn extract_login_url_with_different_port() { + let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3002/oauth/authorize"; + let url = extract_login_url_from_error(err); + assert_eq!(url, Some("http://localhost:3002/oauth/authorize")); + } + #[test] fn parse_credentials_file() { let json = r#"{ diff --git a/server/src/llm/providers/claude_code.rs b/server/src/llm/providers/claude_code.rs index 32734b52..ae2a82fe 100644 --- a/server/src/llm/providers/claude_code.rs +++ b/server/src/llm/providers/claude_code.rs @@ -138,9 +138,11 @@ impl ClaudeCodeProvider { on_token("\n*Refreshing authentication token...*\n"); continue; } - Err(e) => { + Err(_e) => { + let port = crate::http::resolve_port(); + let login_url = format!("http://localhost:{port}/oauth/authorize"); return Err(format!( - "OAuth session expired. Please run `claude login` to re-authenticate. ({e})" + "OAuth session expired or credentials missing. Please log in: {login_url}" )); } }