feat(providers): add Anthropic OAuth subscription support with CLI detection#430
feat(providers): add Anthropic OAuth subscription support with CLI detection#430TheDarkSkyXD wants to merge 2 commits intospacedriveapp:mainfrom
Conversation
…tection Add the ability for users to authenticate with their Anthropic Claude Pro/Max subscription via OAuth instead of requiring a separate API key. The feature detects existing Claude Code CLI installations and surfaces that status in the UI for a streamlined sign-in experience. Backend: - Add GET /api/providers/anthropic/oauth/cli-status endpoint that detects Claude Code CLI installation, version, and auth status by inspecting ~/.claude/ and running `claude auth status` - Add POST /api/providers/anthropic/oauth/start endpoint to initiate the PKCE authorization flow and return the browser authorize URL - Add POST /api/providers/anthropic/oauth/exchange endpoint to exchange the authorization code for access/refresh tokens - Add anthropic_oauth field to ProviderStatus for tracking OAuth state - Add set/clear_anthropic_oauth_credentials methods to LlmManager - Handle DELETE /api/providers/anthropic-oauth for credential removal Frontend: - Add AnthropicOAuthProviderCard below the Anthropic API key card with CLI detection status indicator and sign-in/remove actions - Add AnthropicOAuthDialog with model selector (user picks their model), PKCE flow steps, and authorization code input - Add ClaudeCliStatusResponse, AnthropicOAuthStartResponse, and AnthropicOAuthExchangeResponse types to the API client - Add claudeCliStatus(), startAnthropicOAuth(), and exchangeAnthropicOAuth() API functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WalkthroughAdds Anthropic OAuth PKCE support across frontend and backend: new client API types/methods, Settings UI components and flows (including Claude CLI detection and Tauri openExternal), backend PKCE session handling and endpoints, credential persistence in the LLM manager, and route registrations. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Important Merge conflicts detected (Beta)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can suggest fixes for GitHub Check annotations.Configure the |
|
I tested it and it fullly worked for me. |
| let home = dirs::home_dir().unwrap_or_default(); | ||
| let claude_dir = home.join(".claude"); | ||
| let claude_folder_exists = claude_dir.is_dir(); | ||
| let credentials_file_exists = claude_dir.join("credentials.json").is_file(); |
There was a problem hiding this comment.
dirs::home_dir() can be None; unwrap_or_default() makes .claude relative to CWD and can give false positives. Handling the None case avoids surprising detection.
| let home = dirs::home_dir().unwrap_or_default(); | |
| let claude_dir = home.join(".claude"); | |
| let claude_folder_exists = claude_dir.is_dir(); | |
| let credentials_file_exists = claude_dir.join("credentials.json").is_file(); | |
| let (claude_folder_exists, credentials_file_exists) = match dirs::home_dir() { | |
| Some(home) => { | |
| let claude_dir = home.join(".claude"); | |
| ( | |
| claude_dir.is_dir(), | |
| claude_dir.join("credentials.json").is_file(), | |
| ) | |
| } | |
| None => (false, false), | |
| }; |
| Err(_) => { | ||
| // Command failed to run but binary exists — assume authed (older CLI). | ||
| (true, None) | ||
| } |
There was a problem hiding this comment.
If claude auth status fails to run, returning authenticated=true can produce a pretty confusing false-positive. I’d keep this conservative.
| Err(_) => { | |
| // Command failed to run but binary exists — assume authed (older CLI). | |
| (true, None) | |
| } | |
| Err(_) => (false, None), |
interface/src/routes/Settings.tsx
Outdated
| window.open( | ||
| result.authorize_url, | ||
| "spacebot-anthropic-oauth", | ||
| "popup=true,width=780,height=960,noopener,noreferrer", | ||
| ); |
There was a problem hiding this comment.
window.open can return null (popup blocked), which leaves the dialog in the “paste code” step without ever opening the auth page. Surfacing that case makes the flow less confusing.
| window.open( | |
| result.authorize_url, | |
| "spacebot-anthropic-oauth", | |
| "popup=true,width=780,height=960,noopener,noreferrer", | |
| ); | |
| const popup = window.open( | |
| result.authorize_url, | |
| "spacebot-anthropic-oauth", | |
| "popup=true,width=780,height=960,noopener,noreferrer", | |
| ); | |
| if (!popup) { | |
| setAnthropicOAuthMessage({ | |
| text: "Popup was blocked by the browser. Please allow popups and try again.", | |
| type: "error", | |
| }); | |
| } |
| const handleExchangeAnthropicOAuth = async () => { | ||
| if (!anthropicOAuthState || !anthropicOAuthCodeInput.trim()) return; | ||
| setAnthropicOAuthMessage(null); | ||
| try { | ||
| const result = await exchangeAnthropicOAuthMutation.mutateAsync({ | ||
| code: anthropicOAuthCodeInput.trim(), | ||
| state: anthropicOAuthState, | ||
| }); |
There was a problem hiding this comment.
Since the backend expects the code in the <code>#<state> format, validating the paste upfront gives a clearer error than “token exchange failed”.
| const handleExchangeAnthropicOAuth = async () => { | |
| if (!anthropicOAuthState || !anthropicOAuthCodeInput.trim()) return; | |
| setAnthropicOAuthMessage(null); | |
| try { | |
| const result = await exchangeAnthropicOAuthMutation.mutateAsync({ | |
| code: anthropicOAuthCodeInput.trim(), | |
| state: anthropicOAuthState, | |
| }); | |
| const handleExchangeAnthropicOAuth = async () => { | |
| const code = anthropicOAuthCodeInput.trim(); | |
| if (!anthropicOAuthState || !code) return; | |
| if (!code.includes("#")) { | |
| setAnthropicOAuthMessage({ | |
| text: "Paste the full authorization code (it includes a '#...' suffix).", | |
| type: "error", | |
| }); | |
| return; | |
| } | |
| setAnthropicOAuthMessage(null); | |
| try { | |
| const result = await exchangeAnthropicOAuthMutation.mutateAsync({ | |
| code, | |
| state: anthropicOAuthState, | |
| }); |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@interface/src/routes/Settings.tsx`:
- Around line 576-603: Open the popup synchronously before awaiting the network
call to avoid browser popup blocking: in handleStartAnthropicOAuth, call
window.open with an empty URL (e.g., window.open("", "spacebot-anthropic-oauth",
"popup=true,width=780,height=960")) and keep the returned popup reference (do
NOT include "noopener" or "noreferrer" so the reference is usable); then call
startAnthropicOAuthMutation.mutateAsync({...}); after the response, if success
setAnthropicOAuthState(result.state) and navigate the popup by assigning
popup.location.href = result.authorize_url; if the request fails or the response
is invalid, close the popup (popup.close()) and setAnthropicOAuthMessage
appropriately (use existing error messaging logic); ensure you still clear/set
anthropicOAuthCodeInput and anthropicOAuthMessage as before.
In `@src/api/providers.rs`:
- Around line 1284-1323: The handler currently saves credentials and updates the
LLM manager before attempting to parse/write config.toml, then proceeds to
refresh_defaults_config and emit provider_setup_tx even if the TOML write
failed; fix by making the routing write an explicit success gate: perform
apply_model_routing and the tokio::fs::write(&config_path, ...) first (using
content.parse::<toml_edit::DocumentMut>() and writing doc.to_string()), and only
if that parse+write succeeds then call crate::auth::save_credentials,
llm_manager.set_anthropic_oauth_credentials,
refresh_defaults_config(&state).await, and provider_setup_tx.try_send(...); if
the TOML write fails return an error response immediately (and do not call
refresh_defaults_config or emit ProvidersConfigured). This reordering ensures
config routing is actually applied before committing credentials/manager
updates.
- Around line 963-977: After removing the Anthropic OAuth file and clearing the
cached token in the branch that handles provider == "anthropic-oauth" (where you
call mgr.clear_anthropic_oauth_credentials()), also notify the runtime by
sending the same ProvidersConfigured broadcast used in the add/update/exchange
paths so live agents reload their config; locate the existing code that sends
the ProvidersConfigured event and invoke that same broadcast/send logic
immediately before returning the ProviderUpdateResponse to ensure agents observe
the fallback from OAuth to API key/no provider.
- Around line 1199-1212: The endpoint currently only checks for an empty model
but must also reject models that don't belong to Anthropic; update the
validation in the handler that reads request.model (the same block using
model.trim()) to verify provider/model alignment and return an error
Json(AnthropicOAuthStartResponse { success: false, message: "...", ... }) when
the model is not an Anthropic model. Mirror the same provider/model check logic
used by update_provider and test_provider_model (reuse or call their validation
helper if available) so non-Anthropic model strings are rejected before
proceeding to create the OAuth session.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 97f79c12-6703-4827-a697-be89d5781a57
📒 Files selected for processing (5)
interface/src/api/client.tsinterface/src/routes/Settings.tsxsrc/api/providers.rssrc/api/server.rssrc/llm/manager.rs
| const handleStartAnthropicOAuth = async () => { | ||
| if (!anthropicOAuthModel.trim()) { | ||
| setAnthropicOAuthMessage({ text: "Please select a model first", type: "error" }); | ||
| return; | ||
| } | ||
| setAnthropicOAuthMessage(null); | ||
| setAnthropicOAuthState(null); | ||
| setAnthropicOAuthCodeInput(""); | ||
| try { | ||
| const result = await startAnthropicOAuthMutation.mutateAsync({ | ||
| model: anthropicOAuthModel.trim(), | ||
| }); | ||
| if (!result.success || !result.authorize_url || !result.state) { | ||
| setAnthropicOAuthMessage({ | ||
| text: result.message || "Failed to start OAuth flow", | ||
| type: "error", | ||
| }); | ||
| return; | ||
| } | ||
| setAnthropicOAuthState(result.state); | ||
| window.open( | ||
| result.authorize_url, | ||
| "spacebot-anthropic-oauth", | ||
| "popup=true,width=780,height=960,noopener,noreferrer", | ||
| ); | ||
| } catch (error: any) { | ||
| setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Do browsers preserve user activation for window.open() after awaiting a network request in a click handler, or is the popup typically blocked?
💡 Result:
Browsers don’t reliably preserve “user activation” across an awaited network request for window.open()—so the popup is typically blocked, especially in Firefox and Safari.
- The HTML spec defines transient activation as lasting only a few seconds (user-agent-defined) and expiring by timeout or being consumed by certain APIs. [1]
- MDN documents
window.open()as requiring transient activation and notes it must be called “inside a user interaction … within five seconds,” otherwise it may returnnulldue to popup blocking. [2] - In practice, browsers often treat “must be opened in direct response to user input” as meaning synchronous with the click handler, and async work (like a fetch) breaks that expectation. This is a long-standing pain point and leads to workarounds like opening a blank window immediately, then navigating it later. [3][4]
Practical takeaway: if you await fetch(...) and then call window.open(...), expect it to be blocked in many cases. The common cross-browser pattern is:
const w = window.open('about:blank', '_blank')synchronously in the click, then- after the
await, setw.location = finalUrl(or close it if the request fails). [4]
Sources:
[1] HTML Standard – transient activation duration/expiry & consumption: https://html.spec.whatwg.org/multipage/interaction.html
[2] MDN – Window.open() user activation requirement / popup blocking: https://developer.mozilla.org/en-US/docs/Web/API/Window/open
[3] WICG discussion – async breaks user-gesture-gated APIs like window.open: https://discourse.wicg.io/t/user-gesture-restrictions-and-async-code/1640
[4] Stack Overflow (example) – works in Chrome but blocked in Firefox/Safari after await, with workaround: https://stackoverflow.com/questions/74128322/using-window-open-in-an-async-function-in-firefox-and-safari
🏁 Script executed:
# Check the code around lines 3053-3074 in Settings.tsx
sed -n '3045,3080p' interface/src/routes/Settings.tsxRepository: spacedriveapp/spacebot
Length of output: 1052
Open the auth window before awaiting the network request to avoid popup blocking.
The code at line 596 calls window.open() after mutateAsync() resolves. Browsers don't reliably preserve transient user activation across awaited network requests—the popup will be blocked in Firefox, Safari, and others. Because the dialog doesn't offer a manual fallback URL in the pre-auth state, a blocked popup leaves the user stranded in the sign-in flow.
Open a blank popup window synchronously before the request, then navigate it to the authorization URL after the response resolves. Close the popup if the request fails.
Suggested fix
const handleStartAnthropicOAuth = async () => {
if (!anthropicOAuthModel.trim()) {
setAnthropicOAuthMessage({ text: "Please select a model first", type: "error" });
return;
}
setAnthropicOAuthMessage(null);
setAnthropicOAuthState(null);
setAnthropicOAuthCodeInput("");
+ const popup = window.open(
+ "",
+ "spacebot-anthropic-oauth",
+ "popup=true,width=780,height=960,noopener,noreferrer",
+ );
try {
const result = await startAnthropicOAuthMutation.mutateAsync({
model: anthropicOAuthModel.trim(),
});
if (!result.success || !result.authorize_url || !result.state) {
+ popup?.close();
setAnthropicOAuthMessage({
text: result.message || "Failed to start OAuth flow",
type: "error",
});
return;
}
setAnthropicOAuthState(result.state);
- window.open(
- result.authorize_url,
- "spacebot-anthropic-oauth",
- "popup=true,width=780,height=960,noopener,noreferrer",
- );
+ if (popup) {
+ popup.location.href = result.authorize_url;
+ } else {
+ window.location.assign(result.authorize_url);
+ }
} catch (error: any) {
+ popup?.close();
setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" });
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleStartAnthropicOAuth = async () => { | |
| if (!anthropicOAuthModel.trim()) { | |
| setAnthropicOAuthMessage({ text: "Please select a model first", type: "error" }); | |
| return; | |
| } | |
| setAnthropicOAuthMessage(null); | |
| setAnthropicOAuthState(null); | |
| setAnthropicOAuthCodeInput(""); | |
| try { | |
| const result = await startAnthropicOAuthMutation.mutateAsync({ | |
| model: anthropicOAuthModel.trim(), | |
| }); | |
| if (!result.success || !result.authorize_url || !result.state) { | |
| setAnthropicOAuthMessage({ | |
| text: result.message || "Failed to start OAuth flow", | |
| type: "error", | |
| }); | |
| return; | |
| } | |
| setAnthropicOAuthState(result.state); | |
| window.open( | |
| result.authorize_url, | |
| "spacebot-anthropic-oauth", | |
| "popup=true,width=780,height=960,noopener,noreferrer", | |
| ); | |
| } catch (error: any) { | |
| setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); | |
| } | |
| const handleStartAnthropicOAuth = async () => { | |
| if (!anthropicOAuthModel.trim()) { | |
| setAnthropicOAuthMessage({ text: "Please select a model first", type: "error" }); | |
| return; | |
| } | |
| setAnthropicOAuthMessage(null); | |
| setAnthropicOAuthState(null); | |
| setAnthropicOAuthCodeInput(""); | |
| const popup = window.open( | |
| "", | |
| "spacebot-anthropic-oauth", | |
| "popup=true,width=780,height=960,noopener,noreferrer", | |
| ); | |
| try { | |
| const result = await startAnthropicOAuthMutation.mutateAsync({ | |
| model: anthropicOAuthModel.trim(), | |
| }); | |
| if (!result.success || !result.authorize_url || !result.state) { | |
| popup?.close(); | |
| setAnthropicOAuthMessage({ | |
| text: result.message || "Failed to start OAuth flow", | |
| type: "error", | |
| }); | |
| return; | |
| } | |
| setAnthropicOAuthState(result.state); | |
| if (popup) { | |
| popup.location.href = result.authorize_url; | |
| } else { | |
| window.location.assign(result.authorize_url); | |
| } | |
| } catch (error: any) { | |
| popup?.close(); | |
| setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); | |
| } | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/routes/Settings.tsx` around lines 576 - 603, Open the popup
synchronously before awaiting the network call to avoid browser popup blocking:
in handleStartAnthropicOAuth, call window.open with an empty URL (e.g.,
window.open("", "spacebot-anthropic-oauth", "popup=true,width=780,height=960"))
and keep the returned popup reference (do NOT include "noopener" or "noreferrer"
so the reference is usable); then call
startAnthropicOAuthMutation.mutateAsync({...}); after the response, if success
setAnthropicOAuthState(result.state) and navigate the popup by assigning
popup.location.href = result.authorize_url; if the request fails or the response
is invalid, close the popup (popup.close()) and setAnthropicOAuthMessage
appropriately (use existing error messaging logic); ensure you still clear/set
anthropicOAuthCodeInput and anthropicOAuthMessage as before.
| if provider == "anthropic-oauth" { | ||
| let instance_dir = (**state.instance_dir.load()).clone(); | ||
| let cred_path = crate::auth::credentials_path(&instance_dir); | ||
| if cred_path.exists() { | ||
| tokio::fs::remove_file(&cred_path) | ||
| .await | ||
| .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | ||
| } | ||
| if let Some(mgr) = state.llm_manager.read().await.as_ref() { | ||
| mgr.clear_anthropic_oauth_credentials().await; | ||
| } | ||
| return Ok(Json(ProviderUpdateResponse { | ||
| success: true, | ||
| message: "Anthropic OAuth credentials removed".into(), | ||
| })); |
There was a problem hiding this comment.
Notify the runtime after removing Anthropic OAuth.
This branch clears the file and cached token, then returns without sending ProvidersConfigured. The add/update/exchange paths in this file all broadcast after credential changes, so live agents won't observe the fallback from OAuth to API key (or no provider) until something else reloads them.
Suggested fix
if let Some(mgr) = state.llm_manager.read().await.as_ref() {
mgr.clear_anthropic_oauth_credentials().await;
}
+ state
+ .provider_setup_tx
+ .try_send(crate::ProviderSetupEvent::ProvidersConfigured)
+ .ok();
return Ok(Json(ProviderUpdateResponse {
success: true,
message: "Anthropic OAuth credentials removed".into(),
}));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if provider == "anthropic-oauth" { | |
| let instance_dir = (**state.instance_dir.load()).clone(); | |
| let cred_path = crate::auth::credentials_path(&instance_dir); | |
| if cred_path.exists() { | |
| tokio::fs::remove_file(&cred_path) | |
| .await | |
| .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | |
| } | |
| if let Some(mgr) = state.llm_manager.read().await.as_ref() { | |
| mgr.clear_anthropic_oauth_credentials().await; | |
| } | |
| return Ok(Json(ProviderUpdateResponse { | |
| success: true, | |
| message: "Anthropic OAuth credentials removed".into(), | |
| })); | |
| if provider == "anthropic-oauth" { | |
| let instance_dir = (**state.instance_dir.load()).clone(); | |
| let cred_path = crate::auth::credentials_path(&instance_dir); | |
| if cred_path.exists() { | |
| tokio::fs::remove_file(&cred_path) | |
| .await | |
| .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | |
| } | |
| if let Some(mgr) = state.llm_manager.read().await.as_ref() { | |
| mgr.clear_anthropic_oauth_credentials().await; | |
| } | |
| state | |
| .provider_setup_tx | |
| .try_send(crate::ProviderSetupEvent::ProvidersConfigured) | |
| .ok(); | |
| return Ok(Json(ProviderUpdateResponse { | |
| success: true, | |
| message: "Anthropic OAuth credentials removed".into(), | |
| })); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/providers.rs` around lines 963 - 977, After removing the Anthropic
OAuth file and clearing the cached token in the branch that handles provider ==
"anthropic-oauth" (where you call mgr.clear_anthropic_oauth_credentials()), also
notify the runtime by sending the same ProvidersConfigured broadcast used in the
add/update/exchange paths so live agents reload their config; locate the
existing code that sends the ProvidersConfigured event and invoke that same
broadcast/send logic immediately before returning the ProviderUpdateResponse to
ensure agents observe the fallback from OAuth to API key/no provider.
| let model = request.model.trim().to_string(); | ||
| if model.is_empty() { | ||
| return Ok(Json(AnthropicOAuthStartResponse { | ||
| success: false, | ||
| message: "Model cannot be empty".to_string(), | ||
| authorize_url: None, | ||
| state: None, | ||
| })); | ||
| } | ||
|
|
||
| let mode = match request.mode.as_deref() { | ||
| Some("console") => crate::auth::AuthMode::Console, | ||
| _ => crate::auth::AuthMode::Max, | ||
| }; |
There was a problem hiding this comment.
Reject cross-provider models here too.
This endpoint only checks for empty input. A caller can start an Anthropic OAuth session with a non-Anthropic model string, and Line 1312 will later write that value into routing. update_provider and test_provider_model already enforce provider/model alignment; this path should do the same.
Suggested fix
if model.is_empty() {
return Ok(Json(AnthropicOAuthStartResponse {
success: false,
message: "Model cannot be empty".to_string(),
authorize_url: None,
state: None,
}));
}
+ if !model_matches_provider("anthropic", &model) {
+ return Ok(Json(AnthropicOAuthStartResponse {
+ success: false,
+ message: format!("Model '{}' must use provider 'anthropic'.", request.model),
+ authorize_url: None,
+ state: None,
+ }));
+ }
let mode = match request.mode.as_deref() {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/providers.rs` around lines 1199 - 1212, The endpoint currently only
checks for an empty model but must also reject models that don't belong to
Anthropic; update the validation in the handler that reads request.model (the
same block using model.trim()) to verify provider/model alignment and return an
error Json(AnthropicOAuthStartResponse { success: false, message: "...", ... })
when the model is not an Anthropic model. Mirror the same provider/model check
logic used by update_provider and test_provider_model (reuse or call their
validation helper if available) so non-Anthropic model strings are rejected
before proceeding to create the OAuth session.
| // Save credentials to disk | ||
| let instance_dir = (**state.instance_dir.load()).clone(); | ||
| if let Err(error) = crate::auth::save_credentials(&instance_dir, &credentials) { | ||
| tracing::warn!(%error, "failed to save Anthropic OAuth credentials"); | ||
| return Ok(Json(AnthropicOAuthExchangeResponse { | ||
| success: false, | ||
| message: format!("Failed to save credentials: {error}"), | ||
| })); | ||
| } | ||
|
|
||
| // Update the LLM manager | ||
| if let Some(llm_manager) = state.llm_manager.read().await.as_ref() { | ||
| llm_manager | ||
| .set_anthropic_oauth_credentials(credentials) | ||
| .await; | ||
| } | ||
|
|
||
| // Update model routing in config.toml | ||
| let config_path = state.config_path.read().await.clone(); | ||
| let content = if config_path.exists() { | ||
| tokio::fs::read_to_string(&config_path) | ||
| .await | ||
| .unwrap_or_default() | ||
| } else { | ||
| String::new() | ||
| }; | ||
|
|
||
| if let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() { | ||
| apply_model_routing(&mut doc, &session.model); | ||
| if let Err(error) = tokio::fs::write(&config_path, doc.to_string()).await { | ||
| tracing::warn!(%error, "failed to write config.toml after Anthropic OAuth"); | ||
| } | ||
| } | ||
|
|
||
| refresh_defaults_config(&state).await; | ||
|
|
||
| state | ||
| .provider_setup_tx | ||
| .try_send(crate::ProviderSetupEvent::ProvidersConfigured) | ||
| .ok(); |
There was a problem hiding this comment.
Don't claim routing was applied when the config update failed.
Lines 1311-1315 currently log and continue on TOML parse/write errors, but the handler still refreshes defaults, emits ProvidersConfigured, and returns a success message saying the model was applied. That leaves the instance half-configured: OAuth credentials are live, but routing may still point at the old model. Make the routing write part of the success path, or roll the saved credentials back if it fails.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/providers.rs` around lines 1284 - 1323, The handler currently saves
credentials and updates the LLM manager before attempting to parse/write
config.toml, then proceeds to refresh_defaults_config and emit provider_setup_tx
even if the TOML write failed; fix by making the routing write an explicit
success gate: perform apply_model_routing and the tokio::fs::write(&config_path,
...) first (using content.parse::<toml_edit::DocumentMut>() and writing
doc.to_string()), and only if that parse+write succeeds then call
crate::auth::save_credentials, llm_manager.set_anthropic_oauth_credentials,
refresh_defaults_config(&state).await, and provider_setup_tx.try_send(...); if
the TOML write fails return an error response immediately (and do not call
refresh_defaults_config or emit ProvidersConfigured). This reordering ensures
config routing is actually applied before committing credentials/manager
updates.
- Replace window.open() with openExternal() helper that uses the Tauri shell plugin to open OAuth URLs in the system browser. window.open() in Tauri opens a webview instead of the default browser, breaking the OAuth flow in the desktop app. This also fixes the same latent bug in the existing OpenAI OAuth flow. - Prefix unused `state` parameter in start_anthropic_oauth with underscore to suppress compiler warning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (5)
src/api/providers.rs (4)
1112-1138:⚠️ Potential issue | 🟡 MinorKeep CLI auth detection conservative when
auth statusfails.The
Err(_) => (true, None)fallback reports an authenticated CLI even when the command could not run, so the UI can advertise a streamlined sign-in that does not actually exist.Suggested fix
- Err(_) => { - // Command failed to run but binary exists — assume authed (older CLI). - (true, None) - } + Err(error) => { + tracing::debug!(%error, "claude auth status failed"); + (false, None) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/providers.rs` around lines 1112 - 1138, The match over auth_result is too permissive: the Err(_) arm currently returns (true, None) which falsely reports the CLI as authenticated when the auth status command failed to run; update the Err(_) arm in the auth_result match to return (false, None) (and optionally log the error) so that failures to execute/authenticate are treated conservatively as unauthenticated; refer to the auth_result match block and the logged_in/email extraction logic to locate the change.
963-977:⚠️ Potential issue | 🟠 MajorEmit
ProvidersConfiguredafter removing Anthropic OAuth.This branch clears the persisted token and in-memory OAuth override, then returns without the broadcast used by the add/update/exchange paths. Live agents will keep using the stale provider state until some other reload happens.
Suggested fix
if let Some(mgr) = state.llm_manager.read().await.as_ref() { mgr.clear_anthropic_oauth_credentials().await; } + state + .provider_setup_tx + .try_send(crate::ProviderSetupEvent::ProvidersConfigured) + .ok(); return Ok(Json(ProviderUpdateResponse { success: true, message: "Anthropic OAuth credentials removed".into(), }));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/providers.rs` around lines 963 - 977, In the "anthropic-oauth" removal branch in providers.rs (the block handling provider == "anthropic-oauth"), after removing the credential file and calling mgr.clear_anthropic_oauth_credentials().await, emit the same ProvidersConfigured broadcast used by the add/update/exchange paths so live agents reload; specifically construct/send the ProvidersConfigured event via the existing provider broadcaster (e.g., call state.provider_broadcaster.send(ProvidersConfigured) or use the same helper used elsewhere), handle/ignore the send result as other branches do, then return the existing Ok(Json(ProviderUpdateResponse { ... })) response.
1199-1212:⚠️ Potential issue | 🟠 MajorReject non-Anthropic models before creating the PKCE session.
update_providerandtest_provider_modelalready enforce provider/model alignment, but this path still accepts any non-empty model string. A caller can start Anthropic OAuth with a different provider model and have it written into routing during exchange.Suggested fix
if model.is_empty() { return Ok(Json(AnthropicOAuthStartResponse { success: false, message: "Model cannot be empty".to_string(), authorize_url: None, state: None, })); } + if !model_matches_provider("anthropic", &model) { + return Ok(Json(AnthropicOAuthStartResponse { + success: false, + message: format!("Model '{}' must use provider 'anthropic'.", request.model), + authorize_url: None, + state: None, + })); + } let mode = match request.mode.as_deref() {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/providers.rs` around lines 1199 - 1212, This path currently accepts any non-empty request.model; before creating the PKCE session, validate that the requested model is an Anthropic model and reject others. Use the same validation used by update_provider/test_provider_model (or call test_provider_model) to verify provider/model alignment; if the model is not valid for Anthropic return the existing error Json response (success: false, message). Update the block that sets let model = request.model.trim().to_string(); to perform this check and only proceed to compute mode (crate::auth::AuthMode) and create the PKCE session when the model is confirmed Anthropic.
1284-1329:⚠️ Potential issue | 🟠 MajorDon't report “model applied” unless the routing write succeeded.
This handler saves OAuth credentials and updates
llm_managerbefore the config read/parse/write, then treats routing changes as best-effort (unwrap_or_default(), ignored parse failure, warning-only write failure) while still returning success. That leaves the instance half-configured: OAuth is live, but routing may still point at the old model. As per coding guidelines, "Don't silently discard errors. Nolet _ =on Results. Handle them, log them, or propagate them."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/providers.rs` around lines 1284 - 1329, The handler currently saves credentials (crate::auth::save_credentials) and updates the LLM manager (llm_manager.set_anthropic_oauth_credentials) but treats model routing changes as best-effort (content.parse::<toml_edit::DocumentMut>(), apply_model_routing, tokio::fs::write) while still returning a success response that claims the model was applied; change this so routing failures are not silently ignored: check the parse and write Results, log detailed errors, and if applying routing fails set the AnthropicOAuthExchangeResponse to success: false (and include the error in message) or otherwise only report "Model '...' applied to routing" when tokio::fs::write succeeded; do not use unwrap_or_default/ignored errors—handle or propagate them and ensure the response accurately reflects whether apply_model_routing and the config write completed.interface/src/routes/Settings.tsx (1)
591-605:⚠️ Potential issue | 🔴 CriticalOpen the Anthropic auth window before the await in web builds, and await the launcher.
In non-Tauri builds
openExternal()falls back towindow.open(), but this handler only calls it aftermutateAsync()resolves. Browsers can block that popup, and because the promise is not awaited, a Tauri shell-open failure will not hit thiscatcheither. The dialog can advance to code-entry even though no authorization page actually opened.Do browsers preserve user activation for `window.open()` after awaiting a network request in a click handler, and does a surrounding `try/catch` catch errors from an async function if that promise is not awaited?Suggested fix
const handleStartAnthropicOAuth = async () => { if (!anthropicOAuthModel.trim()) { setAnthropicOAuthMessage({ text: "Please select a model first", type: "error" }); return; } setAnthropicOAuthMessage(null); setAnthropicOAuthState(null); setAnthropicOAuthCodeInput(""); + const popup = !IS_TAURI + ? window.open("", "spacebot-anthropic-oauth", "popup=true,width=780,height=960") + : null; try { const result = await startAnthropicOAuthMutation.mutateAsync({ model: anthropicOAuthModel.trim(), }); if (!result.success || !result.authorize_url || !result.state) { + popup?.close(); setAnthropicOAuthMessage({ text: result.message || "Failed to start OAuth flow", type: "error", }); return; } - setAnthropicOAuthState(result.state); - openExternal(result.authorize_url); + if (IS_TAURI) { + await openExternal(result.authorize_url); + setAnthropicOAuthState(result.state); + } else if (popup) { + popup.location.href = result.authorize_url; + setAnthropicOAuthState(result.state); + } else { + setAnthropicOAuthMessage({ + text: "Popup was blocked. Please allow popups and try again.", + type: "error", + }); + return; + } } catch (error: any) { + popup?.close(); setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); } };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@interface/src/routes/Settings.tsx` around lines 591 - 605, The click handler currently awaits startAnthropicOAuthMutation.mutateAsync before calling openExternal, which can cause browsers to block window.open and also won't surface Tauri open errors; change the flow in the handler so you open the auth window via openExternal (or call a web-specific window.open) before awaiting startAnthropicOAuthMutation.mutateAsync, capture the returned window or promise (so you can await the launcher for Tauri), then await mutateAsync, and only after validate result call setAnthropicOAuthState and setAnthropicOAuthMessage as needed; ensure you await the openExternal/launcher and wrap both operations in the existing try/catch so any shell-open or window-open errors are caught and shown via setAnthropicOAuthMessage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@interface/src/routes/Settings.tsx`:
- Around line 3045-3077: The start form disappears when a start attempt fails
because the JSX only shows the ModelSelect/Button when !hasState && !message;
change the rendering so the error message does not hide the start form: render
the {message} block independently (remove the !hasState guard or move it above
the form) and keep the ModelSelect, onModelChange, modelValue, onStartOAuth
Button and isRequesting controls visible when hasState is false so users can
retry without closing the dialog.
---
Duplicate comments:
In `@interface/src/routes/Settings.tsx`:
- Around line 591-605: The click handler currently awaits
startAnthropicOAuthMutation.mutateAsync before calling openExternal, which can
cause browsers to block window.open and also won't surface Tauri open errors;
change the flow in the handler so you open the auth window via openExternal (or
call a web-specific window.open) before awaiting
startAnthropicOAuthMutation.mutateAsync, capture the returned window or promise
(so you can await the launcher for Tauri), then await mutateAsync, and only
after validate result call setAnthropicOAuthState and setAnthropicOAuthMessage
as needed; ensure you await the openExternal/launcher and wrap both operations
in the existing try/catch so any shell-open or window-open errors are caught and
shown via setAnthropicOAuthMessage.
In `@src/api/providers.rs`:
- Around line 1112-1138: The match over auth_result is too permissive: the
Err(_) arm currently returns (true, None) which falsely reports the CLI as
authenticated when the auth status command failed to run; update the Err(_) arm
in the auth_result match to return (false, None) (and optionally log the error)
so that failures to execute/authenticate are treated conservatively as
unauthenticated; refer to the auth_result match block and the logged_in/email
extraction logic to locate the change.
- Around line 963-977: In the "anthropic-oauth" removal branch in providers.rs
(the block handling provider == "anthropic-oauth"), after removing the
credential file and calling mgr.clear_anthropic_oauth_credentials().await, emit
the same ProvidersConfigured broadcast used by the add/update/exchange paths so
live agents reload; specifically construct/send the ProvidersConfigured event
via the existing provider broadcaster (e.g., call
state.provider_broadcaster.send(ProvidersConfigured) or use the same helper used
elsewhere), handle/ignore the send result as other branches do, then return the
existing Ok(Json(ProviderUpdateResponse { ... })) response.
- Around line 1199-1212: This path currently accepts any non-empty
request.model; before creating the PKCE session, validate that the requested
model is an Anthropic model and reject others. Use the same validation used by
update_provider/test_provider_model (or call test_provider_model) to verify
provider/model alignment; if the model is not valid for Anthropic return the
existing error Json response (success: false, message). Update the block that
sets let model = request.model.trim().to_string(); to perform this check and
only proceed to compute mode (crate::auth::AuthMode) and create the PKCE session
when the model is confirmed Anthropic.
- Around line 1284-1329: The handler currently saves credentials
(crate::auth::save_credentials) and updates the LLM manager
(llm_manager.set_anthropic_oauth_credentials) but treats model routing changes
as best-effort (content.parse::<toml_edit::DocumentMut>(), apply_model_routing,
tokio::fs::write) while still returning a success response that claims the model
was applied; change this so routing failures are not silently ignored: check the
parse and write Results, log detailed errors, and if applying routing fails set
the AnthropicOAuthExchangeResponse to success: false (and include the error in
message) or otherwise only report "Model '...' applied to routing" when
tokio::fs::write succeeded; do not use unwrap_or_default/ignored errors—handle
or propagate them and ensure the response accurately reflects whether
apply_model_routing and the config write completed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f3c9b01c-2433-4f46-ac08-735de1159825
📒 Files selected for processing (2)
interface/src/routes/Settings.tsxsrc/api/providers.rs
| {message && !hasState && ( | ||
| <div | ||
| className={`rounded-md border px-3 py-2 text-sm ${message.type === "success" | ||
| ? "border-green-500/20 bg-green-500/10 text-green-400" | ||
| : "border-red-500/20 bg-red-500/10 text-red-400" | ||
| }`} | ||
| > | ||
| {message.text} | ||
| </div> | ||
| )} | ||
|
|
||
| {!hasState && !message && ( | ||
| <div className="space-y-3"> | ||
| <p className="text-sm text-ink-dull"> | ||
| Choose the model to use, then authorize access in a browser window. | ||
| </p> | ||
| <ModelSelect | ||
| label="Model" | ||
| description="Pick the model to apply to routing" | ||
| value={modelValue} | ||
| onChange={onModelChange} | ||
| provider="anthropic" | ||
| /> | ||
| <Button | ||
| onClick={onStartOAuth} | ||
| disabled={!modelValue.trim()} | ||
| loading={isRequesting} | ||
| variant="outline" | ||
| size="sm" | ||
| > | ||
| Open authorization page | ||
| </Button> | ||
| </div> |
There was a problem hiding this comment.
Keep the start form visible after a start error.
When the start call fails, hasState stays false but message becomes truthy, so the model picker and “Open authorization page” button disappear. Users have to close and reopen the dialog just to retry.
Suggested fix
- {message && !hasState && (
- <div
- className={`rounded-md border px-3 py-2 text-sm ${message.type === "success"
- ? "border-green-500/20 bg-green-500/10 text-green-400"
- : "border-red-500/20 bg-red-500/10 text-red-400"
- }`}
- >
- {message.text}
- </div>
- )}
-
- {!hasState && !message && (
+ {!hasState && (
<div className="space-y-3">
+ {message && (
+ <div
+ className={`rounded-md border px-3 py-2 text-sm ${message.type === "success"
+ ? "border-green-500/20 bg-green-500/10 text-green-400"
+ : "border-red-500/20 bg-red-500/10 text-red-400"
+ }`}
+ >
+ {message.text}
+ </div>
+ )}
<p className="text-sm text-ink-dull">
Choose the model to use, then authorize access in a browser window.
</p>
<ModelSelect📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {message && !hasState && ( | |
| <div | |
| className={`rounded-md border px-3 py-2 text-sm ${message.type === "success" | |
| ? "border-green-500/20 bg-green-500/10 text-green-400" | |
| : "border-red-500/20 bg-red-500/10 text-red-400" | |
| }`} | |
| > | |
| {message.text} | |
| </div> | |
| )} | |
| {!hasState && !message && ( | |
| <div className="space-y-3"> | |
| <p className="text-sm text-ink-dull"> | |
| Choose the model to use, then authorize access in a browser window. | |
| </p> | |
| <ModelSelect | |
| label="Model" | |
| description="Pick the model to apply to routing" | |
| value={modelValue} | |
| onChange={onModelChange} | |
| provider="anthropic" | |
| /> | |
| <Button | |
| onClick={onStartOAuth} | |
| disabled={!modelValue.trim()} | |
| loading={isRequesting} | |
| variant="outline" | |
| size="sm" | |
| > | |
| Open authorization page | |
| </Button> | |
| </div> | |
| {!hasState && ( | |
| <div className="space-y-3"> | |
| {message && ( | |
| <div | |
| className={`rounded-md border px-3 py-2 text-sm ${message.type === "success" | |
| ? "border-green-500/20 bg-green-500/10 text-green-400" | |
| : "border-red-500/20 bg-red-500/10 text-red-400" | |
| }`} | |
| > | |
| {message.text} | |
| </div> | |
| )} | |
| <p className="text-sm text-ink-dull"> | |
| Choose the model to use, then authorize access in a browser window. | |
| </p> | |
| <ModelSelect | |
| label="Model" | |
| description="Pick the model to apply to routing" | |
| value={modelValue} | |
| onChange={onModelChange} | |
| provider="anthropic" | |
| /> | |
| <Button | |
| onClick={onStartOAuth} | |
| disabled={!modelValue.trim()} | |
| loading={isRequesting} | |
| variant="outline" | |
| size="sm" | |
| > | |
| Open authorization page | |
| </Button> | |
| </div> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/routes/Settings.tsx` around lines 3045 - 3077, The start form
disappears when a start attempt fails because the JSX only shows the
ModelSelect/Button when !hasState && !message; change the rendering so the error
message does not hide the start form: render the {message} block independently
(remove the !hasState guard or move it above the form) and keep the ModelSelect,
onModelChange, modelValue, onStartOAuth Button and isRequesting controls visible
when hasState is false so users can retry without closing the dialog.
Summary
Adds the ability for users to authenticate with their Anthropic Claude Pro/Max subscription via OAuth (PKCE flow) instead of requiring a separate API key. This brings Anthropic OAuth support on par with the existing ChatGPT Plus OAuth integration.
Key capabilities:
~/.claude/and runningclaude auth status. When detected, the UI surfaces the user's email and indicates they can sign in quickly since they're already authenticated with Anthropic.claude-sonnet-4-20250514) rather than short aliases.Changes
Backend (
src/)src/api/providers.rsanthropic_oauthtoProviderStatus. AddedClaudeCliStatusResponse,AnthropicOAuthStartRequest/Response,AnthropicOAuthExchangeRequest/Responsetypes. AddedANTHROPIC_OAUTH_SESSIONSstatic for PKCE session tracking. Implementedclaude_cli_status()handler (CLI binary discovery via PATH + fallback paths, version verification, auth status parsing). Implementedstart_anthropic_oauth()handler (PKCE flow initiation with session management). Implementedexchange_anthropic_oauth()handler (code exchange, credential persistence, model routing update). Addedanthropic-oauthcase todelete_provider().src/api/server.rsGET /providers/anthropic/oauth/cli-status,POST /providers/anthropic/oauth/start,POST /providers/anthropic/oauth/exchange.src/llm/manager.rsset_anthropic_oauth_credentials()andclear_anthropic_oauth_credentials()methods toLlmManagerfor runtime credential management.Frontend (
interface/src/)interface/src/api/client.tsanthropic_oauthtoProviderStatus. AddedClaudeCliStatusResponse,AnthropicOAuthStartResponse,AnthropicOAuthExchangeResponsetypes. AddedclaudeCliStatus(),startAnthropicOAuth(),exchangeAnthropicOAuth()API functions.interface/src/routes/Settings.tsxAnthropicOAuthProviderCardcomponent with CLI detection status indicator. AddedAnthropicOAuthDialogcomponent with model selector (ModelSelect), PKCE flow step indicators, and authorization code input. Integrated both into the providers section alongside the existing Anthropic API key card. Added supporting state and mutation hooks.How it works
anthropic_oauth.json, updates the LLM manager, and applies the selected model to all routing rolesTest plan
get_anthropic_provider()logic)🤖 Generated with Claude Code
Note
Implementation Summary: This PR adds Anthropic OAuth authentication with smart CLI detection. The backend implements PKCE-based OAuth flow (10-minute session TTL) with CLI binary detection via
whichcommand and fallback paths, parsesclaude auth statusJSON output for email extraction, and persists OAuth credentials separately from API keys. The frontend provides two new components: a provider card with CLI status indication and a multi-step dialog for PKCE authorization with model selection. The implementation integrates seamlessly with existing provider management—OAuth is stored independently and takes precedence over API keys when both exist. Key additions: 3 API routes, 290 lines of backend logic for OAuth flow + CLI detection, 220 lines of frontend UI components with proper state management and polling.Written by Tembo for commit eedc001.