Skip to content
This repository was archived by the owner on May 22, 2026. It is now read-only.

Add Claude (OAuth Pro/Max) LLM plugin#1

Closed
mguttmann wants to merge 1 commit into
TypeWhisper:mainfrom
mguttmann:add-claude-oauth-plugin
Closed

Add Claude (OAuth Pro/Max) LLM plugin#1
mguttmann wants to merge 1 commit into
TypeWhisper:mainfrom
mguttmann:add-claude-oauth-plugin

Conversation

@mguttmann
Copy link
Copy Markdown

@mguttmann mguttmann commented May 22, 2026

Summary

Adds a new LLM provider plugin: Claude (OAuth Pro/Max). It lets TypeWhisper use an
Anthropic Claude Pro/Max subscription for text post-processing, instead of an Anthropic
API key.

Layout matches plugins/example-llm/:

  • plugins/claude-oauth/manifest.json
  • plugins/claude-oauth/README.md
  • plugins/claude-oauth/LICENSE (GPL-3.0)
  • plugins/claude-oauth/src/Package.swift (SDK pinned from: "1.0.0")
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/*.swift (10 files, source-only)

What it does

PKCE OAuth flow against claude.ai, code exchanged for access + refresh tokens at
platform.claude.com, single-use refresh-token rotation with linear-backoff retries
([2s, 4s, 6s]), atomic keychain persistence via HostServices.storeSecret. A 60-second
background timer refreshes proactively when less than 15 minutes of validity remain.
Surface area is the standard LLMProviderPlugin protocol; no extra SDK extensions.

Heads-up on policy

This plugin uses the same OAuth client identifier that Claude Code itself uses and
identifies API requests as Claude Code so the Pro/Max subscription accepts them. That is
an unofficial use of the subscription per Anthropic's published policy. The README
documents this clearly. I want to flag it at submission rather than at review — if it
doesn't fit the catalog's policy, please close this PR and I'll keep the plugin in my own
repo instead.

Test Plan

cd plugins/claude-oauth/src
swift build

I've also been running the plugin locally against TypeWhisper 1.4.0 (build 803):

  • OAuth login flow works (browser open → paste <code>#<state> → keychain stored)
  • All three models exposed (Haiku 4.5, Sonnet 4.6, Opus 4.7) hit 200 OK against
    api.anthropic.com/v1/messages
  • Background refresh fires when token is close to expiry (verified by watching the
    expiry timestamp update live in settings)
  • Network-drop retries recover transparently (tested by interrupting Wi-Fi mid-call)

Summary by CodeRabbit

  • New Features

    • Added Claude (OAuth Pro/Max) plugin with OAuth sign-in flow, settings UI, model selection, and connection test.
    • Secure token storage with automatic background refresh and on-demand refresh before use.
    • PKCE-backed sign-in and silent refresh scheduling for uninterrupted usage.
  • Documentation

    • Added user-facing README with setup steps, recommended models, and requirements (macOS 14.0+, TypeWhisper 1.4.0+).
  • License

    • Plugin released under GPL-3.0.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

📝 Walkthrough

Walkthrough

Adds a new Claude (OAuth Pro/Max) TypeWhisper plugin: package/manifest/docs, PKCE + OAuth URL builder, OAuth client with exchange/refresh and retries, token persistence and refresh scheduler, Anthropic request builder/client, plugin implementation, and a SwiftUI settings UI.

Changes

Claude OAuth LLM Plugin

Layer / File(s) Summary
Package, metadata, and documentation
plugins/claude-oauth/src/Package.swift, plugins/claude-oauth/src/Package.resolved, plugins/claude-oauth/manifest.json, plugins/claude-oauth/README.md, plugins/claude-oauth/LICENSE
Swift package manifest for macOS 14+ with TypeWhisperPluginSDK dependency, pinned lockfile, plugin manifest declaring metadata/version/license, README and GPLv3 LICENSE added.
OAuth authorization and PKCE
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthURLBuilder.swift, plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift, plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthClient.swift
Authorization URL builder with PKCE/state, PKCEPair generation and base64url encoding, OAuthClient implements code exchange and token refresh with JSON POSTs, typed OAuthError, and retry/backoff logic.
Token storage and refresh scheduling
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/Models.swift, plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift, plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/RefreshScheduler.swift
TokenSet model, TokenStore persists tokens to host secret store (millisecond-precision dates), RefreshThreshold/RefreshScheduler compute refresh needs, and RefreshTimer drives periodic checks.
Anthropic API client and request builder
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift, plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift, plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/Models.swift
Builds /v1/messages requests (system + user, conditional temperature for models), AnthropicClient sends requests with retry/backoff, maps HTTP statuses to AnthropicError (401/403 authExpired, 429 rateLimited with Retry-After, 400 invalidModel parsing), and extracts the first text block.
Plugin core implementation and lifecycle
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift
ClaudeOAuthLLMPlugin implements LLMProviderPlugin: activation stores HostServices and starts refresh timer if tokens exist; process() loads/touches tokens, optionally refreshes immediately, selects model, sends requests, refreshes once on auth expiry and retries, and maps errors to PluginProcessError.
Settings and configuration UI
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift
SwiftUI settings view managing connection state (idle → awaitingCode → connected/expired), PKCE/nonce generation, opening auth URL, code confirmation with CSRF/state validation, saving tokens to TokenStore, test-connection action, periodic token reload, and disconnect.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A plugin hops in from the OAuth stream,
With tokens tucked safe in the keychain's dream,
PKCE pairs spin their SHA-256 tune,
Claude chats back by morning and noon,
Refresh ticks softly, the settings stay bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add Claude (OAuth Pro/Max) LLM plugin' directly and clearly summarizes the main objective of the pull request, which is to introduce a new LLM provider plugin for Claude using OAuth authentication.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (2)
plugins/claude-oauth/README.md (1)

15-18: 💤 Low value

Inconsistent language in setup instructions.

The setup instructions contain German UI strings ("Mit Claude verbinden", "Bestätigen") within otherwise English documentation. This creates inconsistency and may confuse users.

📝 Suggested fix for consistency
-2. Click **Mit Claude verbinden**. Your default browser opens at `claude.ai/oauth/authorize`.
+2. Click **Connect to Claude**. Your default browser opens at `claude.ai/oauth/authorize`.
 3. Log in with your Claude Pro/Max account and confirm the permission grant.
 4. Anthropic shows an authorization code of the form `<code>#<state>`. Copy the whole thing.
-5. Paste it back into the plugin's code field and click **Bestätigen**.
+5. Paste it back into the plugin's code field and click **Confirm**.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/README.md` around lines 15 - 18, The README contains
German UI labels ("Mit Claude verbinden", "Bestätigen") mixed into English
instructions; update those UI strings to match the rest of the doc (e.g.,
replace "Mit Claude verbinden" with "Connect with Claude" and "Bestätigen" with
"Confirm" or the exact English labels used in the plugin UI) so the steps 2 and
5 read consistently in English while preserving the authorization-code
instructions and examples (the `<code>#<state>` token).
plugins/claude-oauth/src/Package.swift (1)

11-14: ⚡ Quick win

Consider tightening the SDK version constraint.

The current constraint from: "1.0.0" will accept any version >= 1.0.0, including potentially breaking changes in 2.0.0 or later. This could cause unexpected plugin failures when the SDK is updated.

🔒 Proposed fix to prevent breaking changes
     dependencies: [
         .package(
             url: "https://github.com/TypeWhisper/TypeWhisperPluginSDK.git",
-            from: "1.0.0"
+            .upToNextMajor(from: "1.0.0")
         ),
     ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Package.swift` around lines 11 - 14, The dependency
on TypeWhisperPluginSDK in Package.swift uses a loose from: "1.0.0" constraint;
tighten it to avoid automatically accepting a 2.x breaking release by replacing
the version specifier for the .package(...) entry referencing
"https://github.com/TypeWhisper/TypeWhisperPluginSDK.git" with a more
restrictive constraint (for example .upToNextMinor(from: "1.0.0") or an
exact/ranged version like 1.0.0..<2.0.0) so only safe or explicitly chosen SDK
releases are allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift`:
- Line 45: The logs in AnthropicClient.swift (e.g., the retry/info log in the
retry loop and the response/error logging in the request/response handling
methods) currently emit raw payloads and response previews using privacy:
.public; change these to avoid exposing bodies by either logging only safe
metadata (status codes, request IDs, attempt counts) with privacy: .private or
by redacting/truncating payload fields before logging, and remove any direct
printing of full response/error bodies in the logger calls referenced (the
logger.info at the retry line and the logger calls around lines 66–67, 77, 83,
91, 99). Ensure only non-public or redacted values are passed to logger and keep
the retry delay and attempt count visible as safe metadata.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift`:
- Line 5: Remove the hardcoded first‑party Claude identity by deleting the
constant claudeCodeIdentity and any usage of it in AnthropicRequestBuilder.swift
(including the similar hardcoded strings at the other occurrence around lines
27–30); instead, use a neutral/default system prompt or accept an optional
configurable identity parameter passed into the request builder (e.g., via the
existing build/request creation method) so requests no longer claim "Claude
Code" by default. Ensure all references to claudeCodeIdentity are removed or
replaced with the neutral/configurable value and update callers to pass an
explicit identity when needed.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift`:
- Around line 39-45: activate(host: HostServices) currently only calls
startRefreshTimer() when currentTokens() != nil, which prevents starting the
background refresh for users who log in after activation; change activate(host:)
to call startRefreshTimer() unconditionally (relying on scheduledRefreshCheck()
to no-op when no tokens exist), and make the same unconditional
startRefreshTimer() change for the other analogous places that guard on
currentTokens() (the other blocks around startRefreshTimer() referenced in the
review, e.g., the similar conditional checks near the settings/login flow and
the refresh-related methods) so the timer always starts and
scheduledRefreshCheck() controls whether refresh work runs.
- Around line 77-80: The token refresh can race between process() and
scheduledRefreshCheck() causing double-rotation and spurious invalid_grant
failures; serialize refreshes by introducing a single in-flight gate around
RefreshScheduler.needsRefresh + refreshAndPersist(currentTokens:store:) (e.g. an
actor, an AsyncMutex, or a shared Task) so only one refresh runs at a time,
and/or on refresh failure with invalid_grant reload the stored tokens and retry
once before treating as terminal; apply the same gate/reload-before-fail logic
to the other similar sites referenced (lines around 144-159 and 176-180) to
ensure all refresh paths use the same serialization and retry behavior.
- Around line 95-108: The catch that handles refreshAndPersist currently
replaces any mapped PluginProcessError with PluginProcessError.authExpired;
change the catch to rethrow the caught error (preserving mapped refresh errors
from refreshAndPersist) instead of always throwing
PluginProcessError.authExpired, and then ensure the retry call to sendAndMap is
wrapped so that if it throws AnthropicError.authExpired you translate that into
PluginProcessError.authExpired before letting it escape. Target the
refreshAndPersist, sendAndMap, AnthropicError.authExpired, and
PluginProcessError.authExpired paths in process().

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift`:
- Around line 122-127: loadInitialState() and refreshDisplayedTokens() never set
ConnectionState.expired so persisted or refreshed expired tokens show as
.connected; update these functions to inspect loadedTokens (from
TokenStore.load()) and any refreshed tokens for expiration (e.g., check
token.expiration or TokenStore's expiry helper) and set state = .expired if any
token is expired, otherwise set .connected (or .idle when no tokens); ensure you
reference loadedTokens, TokenStore, loadInitialState(),
refreshDisplayedTokens(), and ConnectionState.expired when implementing the
check so the UI reflects expired tokens correctly.
- Around line 215-219: The disconnect() method currently swallows errors from
TokenStore(host: host).clear() by using try?; change it to explicitly catch and
handle failures: call TokenStore(host: host).clear() with try and a do/catch,
and on error present or surface the error (e.g., show an alert or propagate)
instead of proceeding to set loadedTokens = nil, state = .idle, and calling
host.notifyCapabilitiesChanged(); only clear loadedTokens, update state, and
notify host after a successful clear. Ensure you reference the disconnect()
function and TokenStore(host: host).clear() when making the change so failures
aren't silently ignored.
- Around line 163-178: The code extracts statePart in confirmCode() but never
verifies it against the stored state nonce (stateNonce), so add a validation
step before calling oauthClient.exchangeAuthorizationCode: compare statePart
(after trimming) to stateNonce and if they differ or stateNonce is nil, set
errorMessage to an appropriate CSRF/session error, set isWorking = false, and
return without calling exchangeAuthorizationCode; only proceed to call
oauthClient.exchangeAuthorizationCode(...) when the state matches. Ensure you
reference the existing symbols confirmCode(), statePart, stateNonce, and
oauthClient.exchangeAuthorizationCode to locate where to insert this check.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift`:
- Around line 1-3: PKCE.swift currently calls SecRandomCopyBytes and uses
kSecRandomDefault and errSecSuccess but lacks the Security import; fix this by
adding import Security at the top of PKCE.swift so the symbols
SecRandomCopyBytes, kSecRandomDefault, and errSecSuccess resolve during SwiftPM
compilation and the PKCE generation functions (those invoking
SecRandomCopyBytes) compile cleanly.

---

Nitpick comments:
In `@plugins/claude-oauth/README.md`:
- Around line 15-18: The README contains German UI labels ("Mit Claude
verbinden", "Bestätigen") mixed into English instructions; update those UI
strings to match the rest of the doc (e.g., replace "Mit Claude verbinden" with
"Connect with Claude" and "Bestätigen" with "Confirm" or the exact English
labels used in the plugin UI) so the steps 2 and 5 read consistently in English
while preserving the authorization-code instructions and examples (the
`<code>#<state>` token).

In `@plugins/claude-oauth/src/Package.swift`:
- Around line 11-14: The dependency on TypeWhisperPluginSDK in Package.swift
uses a loose from: "1.0.0" constraint; tighten it to avoid automatically
accepting a 2.x breaking release by replacing the version specifier for the
.package(...) entry referencing
"https://github.com/TypeWhisper/TypeWhisperPluginSDK.git" with a more
restrictive constraint (for example .upToNextMinor(from: "1.0.0") or an
exact/ranged version like 1.0.0..<2.0.0) so only safe or explicitly chosen SDK
releases are allowed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 42e76541-0275-4cde-b1b8-9faa21c88aba

📥 Commits

Reviewing files that changed from the base of the PR and between 0c13baa and 424429b.

📒 Files selected for processing (15)
  • plugins/claude-oauth/LICENSE
  • plugins/claude-oauth/README.md
  • plugins/claude-oauth/manifest.json
  • plugins/claude-oauth/src/Package.resolved
  • plugins/claude-oauth/src/Package.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/Models.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthClient.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthURLBuilder.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/RefreshScheduler.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift

lastError = error
if attempt < retryDelays.count {
let delay = retryDelays[attempt]
logger.info("Transient error (\(String(describing: error), privacy: .public)), retry \(attempt + 1)/\(self.retryDelays.count) in \(delay)s")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect public logging sites in the Claude OAuth plugin that may expose payloads.
rg -n -C2 'privacy:\s*\.public|body=|preview' plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin

Repository: TypeWhisper/typewhisper-plugins

Length of output: 5061


Don’t log Anthropic response/error payloads with privacy: .public.

plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift emits raw payloads/bodies with public logging, including transient/network errors and response previews/bodies (privacy: .public) at lines 45, 66–67, 77, 83, 91, and 99. Redact/truncate or omit these payload/body fields and log only safe metadata (e.g., status codes, request IDs) using non-public privacy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift` at
line 45, The logs in AnthropicClient.swift (e.g., the retry/info log in the
retry loop and the response/error logging in the request/response handling
methods) currently emit raw payloads and response previews using privacy:
.public; change these to avoid exposing bodies by either logging only safe
metadata (status codes, request IDs, attempt counts) with privacy: .private or
by redacting/truncating payload fields before logging, and remove any direct
printing of full response/error bodies in the logger calls referenced (the
logger.info at the retry line and the logger calls around lines 66–67, 77, 83,
91, 99). Ensure only non-public or redacted values are passed to logger and keep
the retry delay and attempt count visible as safe metadata.


public enum AnthropicRequestBuilder {
public static let messagesEndpoint = "https://api.anthropic.com/v1/messages"
public static let claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the hardcoded first-party Claude Code identity.

These lines make every request claim it is Anthropic's official CLI, while the PR context explicitly describes this integration as unofficial. Keeping that assertion in the shipped request body creates an avoidable compliance/account-risk beyond the README disclosure.

Also applies to: 27-30

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift`
at line 5, Remove the hardcoded first‑party Claude identity by deleting the
constant claudeCodeIdentity and any usage of it in AnthropicRequestBuilder.swift
(including the similar hardcoded strings at the other occurrence around lines
27–30); instead, use a neutral/default system prompt or accept an optional
configurable identity parameter passed into the request builder (e.g., via the
existing build/request creation method) so requests no longer claim "Claude
Code" by default. Ensure all references to claudeCodeIdentity are removed or
replaced with the neutral/configurable value and update callers to pass an
explicit identity when needed.

Comment on lines +39 to +45
public func activate(host: HostServices) {
self.host = host
// Start scheduler only if we already have tokens — otherwise no-op until user logs in.
if currentTokens() != nil {
startRefreshTimer()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Start the refresh timer after in-session login too.

Line 42 only starts the timer when tokens already exist during activate(). Since settingsView has no callback into the plugin, a user who connects after activation never gets the documented background refresh until restart. Starting the timer unconditionally here is the simplest fix because scheduledRefreshCheck already no-ops when no tokens exist.

Suggested fix
 public func activate(host: HostServices) {
     self.host = host
-    // Start scheduler only if we already have tokens — otherwise no-op until user logs in.
-    if currentTokens() != nil {
-        startRefreshTimer()
-    }
+    refreshTimer?.stop()
+    startRefreshTimer()
 }

Also applies to: 53-55, 167-180

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift`
around lines 39 - 45, activate(host: HostServices) currently only calls
startRefreshTimer() when currentTokens() != nil, which prevents starting the
background refresh for users who log in after activation; change activate(host:)
to call startRefreshTimer() unconditionally (relying on scheduledRefreshCheck()
to no-op when no tokens exist), and make the same unconditional
startRefreshTimer() change for the other analogous places that guard on
currentTokens() (the other blocks around startRefreshTimer() referenced in the
review, e.g., the similar conditional checks near the settings/login flow and
the refresh-related methods) so the timer always starts and
scheduledRefreshCheck() controls whether refresh work runs.

Comment on lines +77 to +80
// Refresh immediately if we're within the immediate threshold (5 min).
if RefreshScheduler.needsRefresh(tokens: tokens, threshold: .immediate) {
tokens = try await refreshAndPersist(currentTokens: tokens, store: store)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Serialize refresh-token rotation across foreground and background refreshes.

This plugin can refresh from process() and scheduledRefreshCheck() at the same time, and the PR context says refresh tokens are single-use rotated. Two callers racing on the same stored refresh token will make one succeed and the other hit invalid_grant, which this code treats as terminal even though a newer token set may already have been saved. Refresh needs a single in-flight gate here (actor/lock/shared task), or at minimum a reload-before-fail check on invalid_grant.

Also applies to: 144-159, 176-180

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift`
around lines 77 - 80, The token refresh can race between process() and
scheduledRefreshCheck() causing double-rotation and spurious invalid_grant
failures; serialize refreshes by introducing a single in-flight gate around
RefreshScheduler.needsRefresh + refreshAndPersist(currentTokens:store:) (e.g. an
actor, an AsyncMutex, or a shared Task) so only one refresh runs at a time,
and/or on refresh failure with invalid_grant reload the stored tokens and retry
once before treating as terminal; apply the same gate/reload-before-fail logic
to the other similar sites referenced (lines around 144-159 and 176-180) to
ensure all refresh paths use the same serialization and retry behavior.

Comment on lines +95 to +108
} catch AnthropicError.authExpired {
// Token was rejected mid-flight (e.g. revoked) — refresh once and retry.
do {
tokens = try await refreshAndPersist(currentTokens: tokens, store: store)
} catch {
throw PluginProcessError.authExpired
}
// Any error from the retry is also mapped to PluginProcessError via sendAndMap.
return try await sendAndMap(
accessToken: tokens.accessToken,
model: chosenModel,
systemPrompt: systemPrompt,
userText: userText
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the auth-refresh retry path on PluginProcessError.

refreshAndPersist() already maps refresh failures, but Lines 97-101 squash every one of them into .authExpired. Then the retried send can still throw raw AnthropicError.authExpired, which leaks the lower-level error type out of process(). Preserve the mapped refresh error, and explicitly map a second auth failure back to PluginProcessError.authExpired.

Suggested fix
         } catch AnthropicError.authExpired {
             // Token was rejected mid-flight (e.g. revoked) — refresh once and retry.
-            do {
-                tokens = try await refreshAndPersist(currentTokens: tokens, store: store)
-            } catch {
-                throw PluginProcessError.authExpired
-            }
-            // Any error from the retry is also mapped to PluginProcessError via sendAndMap.
-            return try await sendAndMap(
-                accessToken: tokens.accessToken,
-                model: chosenModel,
-                systemPrompt: systemPrompt,
-                userText: userText
-            )
+            tokens = try await refreshAndPersist(currentTokens: tokens, store: store)
+            do {
+                return try await sendAndMap(
+                    accessToken: tokens.accessToken,
+                    model: chosenModel,
+                    systemPrompt: systemPrompt,
+                    userText: userText
+                )
+            } catch AnthropicError.authExpired {
+                throw PluginProcessError.authExpired
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift`
around lines 95 - 108, The catch that handles refreshAndPersist currently
replaces any mapped PluginProcessError with PluginProcessError.authExpired;
change the catch to rethrow the caught error (preserving mapped refresh errors
from refreshAndPersist) instead of always throwing
PluginProcessError.authExpired, and then ensure the retry call to sendAndMap is
wrapped so that if it throws AnthropicError.authExpired you translate that into
PluginProcessError.authExpired before letting it escape. Target the
refreshAndPersist, sendAndMap, AnthropicError.authExpired, and
PluginProcessError.authExpired paths in process().

Comment on lines +122 to +127
private func loadInitialState() {
loadedTokens = TokenStore(host: host).load()
selectedModel = (host.userDefault(forKey: ClaudeOAuthLLMPlugin.selectedModelKey) as? String)
?? ClaudeOAuthLLMPlugin.defaultModel
state = (loadedTokens == nil) ? .idle : .connected
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

expired state is never entered from persisted/refresh paths.

ConnectionState.expired has UI but isn’t set in loadInitialState() or refreshDisplayedTokens(). Expired tokens will still appear as connected.

Suggested fix
+    private func resolveState(from tokens: TokenSet?) -> ConnectionState {
+        guard let tokens else { return .idle }
+        return tokens.expiresAt <= Date() ? .expired : .connected
+    }
+
     private func loadInitialState() {
         loadedTokens = TokenStore(host: host).load()
         selectedModel = (host.userDefault(forKey: ClaudeOAuthLLMPlugin.selectedModelKey) as? String)
             ?? ClaudeOAuthLLMPlugin.defaultModel
-        state = (loadedTokens == nil) ? .idle : .connected
+        state = resolveState(from: loadedTokens)
     }
@@
     private func refreshDisplayedTokens() {
         guard state == .connected || state == .expired else { return }
         let fresh = TokenStore(host: host).load()
-        if let fresh {
-            loadedTokens = fresh
-            if state == .expired { state = .connected }
-        } else {
-            loadedTokens = nil
-            state = .idle
-        }
+        loadedTokens = fresh
+        state = resolveState(from: fresh)
     }

Also applies to: 132-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift`
around lines 122 - 127, loadInitialState() and refreshDisplayedTokens() never
set ConnectionState.expired so persisted or refreshed expired tokens show as
.connected; update these functions to inspect loadedTokens (from
TokenStore.load()) and any refreshed tokens for expiration (e.g., check
token.expiration or TokenStore's expiry helper) and set state = .expired if any
token is expired, otherwise set .connected (or .idle when no tokens); ensure you
reference loadedTokens, TokenStore, loadInitialState(),
refreshDisplayedTokens(), and ConnectionState.expired when implementing the
check so the UI reflects expired tokens correctly.

Comment on lines +215 to +219
private func disconnect() {
try? TokenStore(host: host).clear()
loadedTokens = nil
state = .idle
host.notifyCapabilitiesChanged()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t swallow token-clear failures during disconnect.

Line 216 ignores keychain-clear errors. If clear fails, the UI still reports disconnected while credentials may remain stored.

Suggested fix
 private func disconnect() {
-    try? TokenStore(host: host).clear()
+    do {
+        try TokenStore(host: host).clear()
+    } catch {
+        errorMessage = "Trennen fehlgeschlagen: \(error.localizedDescription)"
+        return
+    }
     loadedTokens = nil
     state = .idle
     host.notifyCapabilitiesChanged()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift`
around lines 215 - 219, The disconnect() method currently swallows errors from
TokenStore(host: host).clear() by using try?; change it to explicitly catch and
handle failures: call TokenStore(host: host).clear() with try and a do/catch,
and on error present or surface the error (e.g., show an alert or propagate)
instead of proceeding to set loadedTokens = nil, state = .idle, and calling
host.notifyCapabilitiesChanged(); only clear loadedTokens, update state, and
notify host after a successful clear. Ensure you reference the disconnect()
function and TokenStore(host: host).clear() when making the change so failures
aren't silently ignored.

Comment on lines +1 to +3
import Foundation
import CryptoKit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify Security symbols are used and whether Security is explicitly imported.
rg -n -C2 'import Security|SecRandomCopyBytes|kSecRandomDefault|errSecSuccess' plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift

Repository: TypeWhisper/typewhisper-plugins

Length of output: 457


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift"

echo "== Top of file =="
nl -ba "$FILE" | head -n 40

echo
echo "== Any 'import Security' in file? =="
rg -n "import Security" "$FILE" || true

Repository: TypeWhisper/typewhisper-plugins

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift"

echo "== File head (first 40 lines) =="
sed -n '1,40p' "$FILE"

echo
echo "== Any 'import Security' in file? =="
rg -n "import Security" "$FILE" || true

echo
echo "== Security symbols usage in file =="
rg -n "SecRandomCopyBytes|kSecRandomDefault|errSecSuccess" "$FILE"

Repository: TypeWhisper/typewhisper-plugins

Length of output: 1255


🌐 Web query:

SecRandomCopyBytes kSecRandomDefault errSecSuccess Swift import Security module

💡 Result:

In Swift, use the Security framework function SecRandomCopyBytes (from module Security) with kSecRandomDefault and check the returned status for errSecSuccess. Swift API (signature): func SecRandomCopyBytes(_ rnd: SecRandomRef?, _ count: Int, _ bytes: UnsafeMutableRawPointer) -> Int32 [1] Example (generate N secure random bytes): import Foundation import Security let count = 32 var bytes = [UInt8](repeating: 0, count: count) let status = bytes.withUnsafeMutableBytes { rawBuf -> Int32 in SecRandomCopyBytes(kSecRandomDefault, count, rawBuf.baseAddress!) } if status == errSecSuccess { // bytes now contain cryptographically secure random data } else { // handle error } Notes: - Pass kSecRandomDefault as the random number generator to use the system’s default secure RNG. [1] - The function fills the buffer you provide with cryptographically secure random bytes. [1] - Always test the returned Int32 status; on success it is errSecSuccess, otherwise it is some other error code. [1] Import: ensure you have - import Security [1] If you still see errSecSuccess-related issues, they are almost always due to buffer/size/pointer problems (e.g., passing an incorrectly sized buffer or an invalid pointer), and should be diagnosed by checking the returned status value and ensuring the buffer is at least count bytes. [1]

Citations:


Add import Security to support SecRandomCopyBytes in PKCE.swift

PKCE.swift calls SecRandomCopyBytes with kSecRandomDefault and compares against errSecSuccess, but only imports Foundation and CryptoKit; import Security to avoid SwiftPM compile failures.

💡 Proposed fix
 import Foundation
 import CryptoKit
+import Security
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift` around lines 1
- 3, PKCE.swift currently calls SecRandomCopyBytes and uses kSecRandomDefault
and errSecSuccess but lacks the Security import; fix this by adding import
Security at the top of PKCE.swift so the symbols SecRandomCopyBytes,
kSecRandomDefault, and errSecSuccess resolve during SwiftPM compilation and the
PKCE generation functions (those invoking SecRandomCopyBytes) compile cleanly.

@mguttmann mguttmann force-pushed the add-claude-oauth-plugin branch from 424429b to 4d0682e Compare May 22, 2026 14:41
@mguttmann
Copy link
Copy Markdown
Author

Pushed a force-update addressing internal review findings before this PR gets attention:

Code fixes

  • OAuthClient.refresh() now throws malformedResponse when the server omits the new refresh_token instead of falling back to the (already consumed) old one — refresh tokens are single-use per Anthropic's spec.
  • ClaudeSettingsView.confirmCode() now validates the state returned in the authorization code against the nonce sent in the authorize URL (CSRF guard that was missing).
  • ClaudeOAuthLLMPlugin.activate() now fires an immediate-threshold refresh in a detached Task when tokens are already present, so a stale token after the app was killed for a while gets refreshed proactively instead of on the next process() hot path.

README

  • German UI button labels (Mit Claude verbinden, Bestätigen) now also include English translations in setup steps so non-German readers can follow the flow.

Housekeeping

  • Squashed the six per-step commits into one clean catalog-submission commit.
  • Rewrote the author email on commits to a GitHub no-reply address.

All 53 tests in the upstream dev tree still pass. swift build from plugins/claude-oauth/src/ succeeds.

Ready for review when you get to it.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift (1)

1-11: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Add import Security for PKCE RNG symbols.

Line 10 and Line 11 use Security APIs, but the module import is missing in this file. That can break SwiftPM compilation with unresolved identifiers.

Proposed fix
 import Foundation
 import CryptoKit
+import Security
#!/bin/bash
set -euo pipefail

FILE="plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift"

echo "== imports =="
rg -n '^\s*import\s+' "$FILE"

echo
echo "== Security symbol usage =="
rg -n 'SecRandomCopyBytes|kSecRandomDefault|errSecSuccess' "$FILE"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift` around lines 1
- 11, The file is missing the Security module import used by the PKCE RNG calls;
add "import Security" at the top of the file so symbols used in
PKCEPair.generate (SecRandomCopyBytes, kSecRandomDefault, errSecSuccess)
resolve. Update the imports section of the PKCE.swift file to include Security
alongside Foundation and CryptoKit so the PKCEPair.generate function compiles
cleanly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift`:
- Around line 174-177: The CSRF state check currently only runs when statePart
is non-nil, allowing a missing statePart to bypass validation; update the
handling in the OAuth callback (where statePart, stateNonce, and errorMessage
are used) to treat a nil statePart as a validation failure: if statePart is nil
or statePart != stateNonce set errorMessage to the same CSRF error text and
return. Ensure you reference statePart and stateNonce in a single conditional
that covers both nil and mismatch cases so missing state values no longer bypass
the check.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift`:
- Around line 4-31: TokenStore is marked `@unchecked` Sendable but holds shared
JSONEncoder/JSONDecoder instances (encoder/decoder) that are used in save(_:)
and load(), which can race; change to create a fresh JSONEncoder in save(_:) and
a fresh JSONDecoder in load() (setting dateEncodingStrategy/dateDecodingStrategy
on each instance) and remove the stored private let encoder/decoder properties
so encoding/decoding are per-call and thread-safe; keep existing behavior
(including throwing TokenStoreError.encodingFailed and using Self.secretKey and
host.storeSecret/loadSecret) otherwise.

---

Duplicate comments:
In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift`:
- Around line 1-11: The file is missing the Security module import used by the
PKCE RNG calls; add "import Security" at the top of the file so symbols used in
PKCEPair.generate (SecRandomCopyBytes, kSecRandomDefault, errSecSuccess)
resolve. Update the imports section of the PKCE.swift file to include Security
alongside Foundation and CryptoKit so the PKCEPair.generate function compiles
cleanly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 42f2fc40-ef0a-4e62-b48b-a279d32fac22

📥 Commits

Reviewing files that changed from the base of the PR and between 424429b and 4d0682e.

📒 Files selected for processing (15)
  • plugins/claude-oauth/LICENSE
  • plugins/claude-oauth/README.md
  • plugins/claude-oauth/manifest.json
  • plugins/claude-oauth/src/Package.resolved
  • plugins/claude-oauth/src/Package.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/Models.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthClient.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthURLBuilder.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/RefreshScheduler.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift
✅ Files skipped from review due to trivial changes (4)
  • plugins/claude-oauth/src/Package.swift
  • plugins/claude-oauth/LICENSE
  • plugins/claude-oauth/manifest.json
  • plugins/claude-oauth/src/Package.resolved

Comment thread plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift Outdated
Comment on lines +4 to +31
public final class TokenStore: @unchecked Sendable {
public static let secretKey = "com.guttmann.typewhisper-claude.oauth"

private let host: HostServices
private let encoder: JSONEncoder
private let decoder: JSONDecoder

public init(host: HostServices) {
self.host = host
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .millisecondsSince1970
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .millisecondsSince1970
}

public func save(_ tokens: TokenSet) throws {
let data = try encoder.encode(tokens)
guard let json = String(data: data, encoding: .utf8) else {
throw TokenStoreError.encodingFailed
}
try host.storeSecret(key: Self.secretKey, value: json)
}

public func load() -> TokenSet? {
guard let json = host.loadSecret(key: Self.secretKey),
let data = json.data(using: .utf8) else { return nil }
return try? decoder.decode(TokenSet.self, from: data)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift"

echo "== sendable declaration and shared coder fields =="
rg -n -C2 '`@unchecked` Sendable|JSONEncoder|JSONDecoder' "$FILE"

echo
echo "== method bodies using shared encoder/decoder =="
rg -n -C3 'func save|func load|encoder\.|decoder\.' "$FILE"

Repository: TypeWhisper/typewhisper-plugins

Length of output: 1672


Avoid sharing JSONEncoder/JSONDecoder across concurrent use in @unchecked Sendable TokenStore.

TokenStore is @unchecked Sendable but it keeps shared private let encoder: JSONEncoder and private let decoder: JSONDecoder initialized in init(host:), then uses them in save(_:) (encoder.encode) and load() (decoder.decode), which can race if TokenStore is accessed concurrently. Prefer creating encoder/decoder per call (e.g., like the proposed diff).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift` around
lines 4 - 31, TokenStore is marked `@unchecked` Sendable but holds shared
JSONEncoder/JSONDecoder instances (encoder/decoder) that are used in save(_:)
and load(), which can race; change to create a fresh JSONEncoder in save(_:) and
a fresh JSONDecoder in load() (setting dateEncodingStrategy/dateDecodingStrategy
on each instance) and remove the stored private let encoder/decoder properties
so encoding/decoding are per-call and thread-safe; keep existing behavior
(including throwing TokenStoreError.encodingFailed and using Self.secretKey and
host.storeSecret/loadSecret) otherwise.

A third-party LLM provider plugin that uses an Anthropic Claude Pro/Max
subscription instead of an API key. PKCE OAuth against claude.ai with
single-use refresh-token rotation, atomic Keychain persistence via
HostServices.storeSecret, and a four-state SwiftUI settings view.

The README is upfront about this being an unofficial use of the
subscription — flagging it at submission so the catalog maintainer
can decide whether it fits the catalog's policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mguttmann mguttmann force-pushed the add-claude-oauth-plugin branch from 4d0682e to 3d74bdd Compare May 22, 2026 14:45
@mguttmann
Copy link
Copy Markdown
Author

One more iteration based on a second internal review pass:

Tightened state-nonce guard
ClaudeSettingsView.confirmCode() previously only rejected a mismatching state (where <code>#<wrongstate> was pasted). A bare code without any #<state> suffix would silently bypass the CSRF check. Now both cases — absent state and mismatching state — produce distinct user-visible errors:

  • absent state → "Code unvollständig — bitte den ganzen Code inklusive '#…' einfügen."
  • mismatched state → "Code stimmt nicht zur Anmeldung — bitte 'Mit Claude verbinden' erneut starten."

Known follow-up (not blocking this PR)
There is a narrow race in ClaudeOAuthLLMPlugin.activate() between the 60-second timer's first tick and the immediate-threshold Task spawned right after — if a refresh is needed and both fire within the same network round-trip, both POST the same single-use refresh token. Probability is low (requires app to launch from sleep with <15min token remaining) but real. Plan: tighten with an actor-isolated refresh state in a follow-up if it ever shows up in practice.

Branch amended + force-pushed: 3d74bdd.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift (1)

99-113: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

AnthropicError.authExpired leaks from the retry path.

If the retried sendAndMap call (line 107) throws AnthropicError.authExpired, it escapes process() unmapped since the outer catch only handles the first authExpired. This leaks an internal error type. Wrap the retry in a catch that maps a second authExpired to PluginProcessError.authExpired.

🐛 Proposed fix
         } catch AnthropicError.authExpired {
             // Token was rejected mid-flight (e.g. revoked) — refresh once and retry.
             do {
                 tokens = try await refreshAndPersist(currentTokens: tokens, store: store)
             } catch {
                 throw PluginProcessError.authExpired
             }
-            // Any error from the retry is also mapped to PluginProcessError via sendAndMap.
-            return try await sendAndMap(
-                accessToken: tokens.accessToken,
-                model: chosenModel,
-                systemPrompt: systemPrompt,
-                userText: userText
-            )
+            // Retry once; map a second authExpired to PluginProcessError.
+            do {
+                return try await sendAndMap(
+                    accessToken: tokens.accessToken,
+                    model: chosenModel,
+                    systemPrompt: systemPrompt,
+                    userText: userText
+                )
+            } catch AnthropicError.authExpired {
+                throw PluginProcessError.authExpired
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift`
around lines 99 - 113, The retry path after catching AnthropicError.authExpired
currently calls sendAndMap directly and can let a second
AnthropicError.authExpired escape process(); wrap the retry call (the second
sendAndMap invocation) in its own do/catch so that if it throws
AnthropicError.authExpired you rethrow PluginProcessError.authExpired (just as
you do for the first refresh failure), keeping refreshAndPersist, sendAndMap and
the mapping to PluginProcessError.authExpired in place so no AnthropicError
leaks out of process().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift`:
- Around line 42-46: The doc comment on modelAcceptsTemperature should avoid
quoting the undocumented verbatim 400 message; update the comment to state
generally that newer Opus 4.7+ models reject the temperature parameter rather
than including the specific quoted text, and keep the current prefix check
(!model.hasPrefix("claude-opus-4-7")) as the correct gating logic for Opus 4.7
model IDs in AnthropicRequestBuilder.swift.

---

Duplicate comments:
In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift`:
- Around line 99-113: The retry path after catching AnthropicError.authExpired
currently calls sendAndMap directly and can let a second
AnthropicError.authExpired escape process(); wrap the retry call (the second
sendAndMap invocation) in its own do/catch so that if it throws
AnthropicError.authExpired you rethrow PluginProcessError.authExpired (just as
you do for the first refresh failure), keeping refreshAndPersist, sendAndMap and
the mapping to PluginProcessError.authExpired in place so no AnthropicError
leaks out of process().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 94344d8e-8dd3-4076-a26d-41631699590c

📥 Commits

Reviewing files that changed from the base of the PR and between 4d0682e and 3d74bdd.

📒 Files selected for processing (15)
  • plugins/claude-oauth/LICENSE
  • plugins/claude-oauth/README.md
  • plugins/claude-oauth/manifest.json
  • plugins/claude-oauth/src/Package.resolved
  • plugins/claude-oauth/src/Package.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicClient.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeOAuthLLMPlugin.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/ClaudeSettingsView.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/Models.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthClient.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/OAuthURLBuilder.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/PKCE.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/RefreshScheduler.swift
  • plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/TokenStore.swift
✅ Files skipped from review due to trivial changes (3)
  • plugins/claude-oauth/manifest.json
  • plugins/claude-oauth/src/Package.resolved
  • plugins/claude-oauth/LICENSE

Comment on lines +42 to +46
/// Whether the given model accepts the `temperature` parameter. Newer reasoning models
/// (Opus 4.7+) reject it with a 400 "deprecated for this model" error.
public static func modelAcceptsTemperature(_ model: String) -> Bool {
!model.hasPrefix("claude-opus-4-7")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Anthropic Claude Opus 4 model name temperature parameter

💡 Result:

Anthropic Claude Opus 4 (Claude API) temperature parameter: - Field name: temperature (part of the Messages API request payload) [1]. - Default: 1.0 if you omit temperature [2]. - Allowed range: 0.0 to 1.0 (values outside this range should be rejected) [2]. - Behavior: lower values make outputs more deterministic; higher values increase randomness/variety [2]. - Note: even at temperature = 0.0, results are not fully deterministic [2]. Opus 4 model name / API ID (use in the model field): - As of the latest Opus 4 line shown in Anthropic’s model overview, the Claude API IDs/aliases include claude-opus-4-7 for Opus 4.7 [3]. (There are also earlier pinned versions such as claude-opus-4-6 shown in the same overview.) [3] Example (Messages API): { "model": "claude-opus-4-7", "max_tokens": 1024, "temperature": 0.2, "messages": [{"role": "user", "content": "Hello, Claude"}] } Key reference: the Anthropic SDK/API type definition for temperature states default, range, and non-determinism at 0.0 [2].

Citations:


Align Opus 4.7 temperature gating with Anthropic model IDs

  • hasPrefix("claude-opus-4-7") matches the documented Claude API model ID/alias (claude-opus-4-7) for Opus 4.7, so the prefix check is appropriate.
  • The doc comment’s quoted 400 message text ("deprecated for this model") isn’t substantiated by the checked references; rewrite it to a non-verbatim/general description.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/claude-oauth/src/Sources/ClaudeOAuthPlugin/AnthropicRequestBuilder.swift`
around lines 42 - 46, The doc comment on modelAcceptsTemperature should avoid
quoting the undocumented verbatim 400 message; update the comment to state
generally that newer Opus 4.7+ models reject the temperature parameter rather
than including the specific quoted text, and keep the current prefix check
(!model.hasPrefix("claude-opus-4-7")) as the correct gating logic for Opus 4.7
model IDs in AnthropicRequestBuilder.swift.

@SeoFood
Copy link
Copy Markdown
Contributor

SeoFood commented May 22, 2026

Thanks for being explicit about the policy risk in the PR body.

I can also see why you opened this PR here: our public plugin docs currently point contributors to this repository for community plugin submissions. So this is not a case of ignoring the documented submission path.

That said, we can't accept this into the TypeWhisper plugin catalog. The implementation relies on Claude Code OAuth credentials and identifies requests as Claude Code so that a Pro/Max subscription accepts them. That crosses the policy/trust boundary we need for cataloged plugins: TypeWhisper should not ship or endorse a plugin that impersonates an Anthropic first-party client or uses subscription credentials as API access.

This repository is for plugins that can be reviewed, built, and published through TypeWhisper's community catalog flow. A plugin based on unofficial Claude Code OAuth behavior is not a good fit for that catalog. If you want to keep experimenting with it, it should live in your own repository instead of being submitted for TypeWhisper publication.

The acceptable paths for a TypeWhisper-catalog plugin would be:

  • use the official Anthropic API with a user-provided API key / Claude Platform billing, or
  • if/when applicable, rebuild around Anthropic's official Agent SDK subscription flow rather than the Claude Code OAuth client.

Closing this PR for policy and catalog-fit reasons, not as a judgment on the implementation quality.

@mguttmann
Copy link
Copy Markdown
Author

Thanks for the clear and respectful close — that's exactly the right call given the constraints, and I appreciate you spelling out the two acceptable paths.

For anyone landing on this thread later (or for the maintainers when they revisit catalog policy): Anthropic announced a relevant change that ships June 15, 2026. Subscription plans get a separate "Agent SDK Credit pool" on top of the existing interactive limit:

  • Pro: $20/month • Max 5×: $100/month • Max 20×: $200/month (monthly, no rollover)
  • Covers Agent SDK, claude -p, Claude Code GitHub Actions, and third-party Agent apps (OpenClaw, Conductor, Zed are explicitly named in Anthropic's coverage)
  • Interactive use (Claude.ai chat, Claude Code in terminal/IDE, Cowork) stays on the existing subscription pool, separate from this

Anthropic's own docs:

Activation emails with implementation details are scheduled for June 8, with the new flow live on June 15. What's not yet documented publicly is the exact authentication mechanism a third-party Swift app would use to opt into the Agent SDK credit pool — whether the official Python/TypeScript SDK libraries are mandatory, or whether direct OAuth against the new subscription-Agent-SDK auth endpoint is enough.

I'll re-evaluate this plugin against the official Agent SDK flow once those details land. If the spec allows a Swift app to authenticate cleanly against the Agent SDK credit pool without impersonating Claude Code, that would address the exact policy concern you raised and might be worth a fresh PR.

In the meantime: PR stays closed, plugin stays in my own repo for personal use, no further action expected here. Just leaving the pointer in case it's useful when you next look at catalog-policy questions for subscription-based plugins.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants