← Back to task

Commit e5672cca

commit e5672cca564a7ff98949fd1396f60459d14cec53
Author: Coder Agent <coder@agents.omni>
Date:   Sun Apr 12 10:41:24 2026

    ava: harden claude oauth token refresh handling
    
    Investigated Ava logs and found claude-oauth calls failing with 401
    "Invalid authentication credentials" and no refresh attempt.
    
    Fixes:
    - Treat Anthropic OAuth 401 responses as refreshable once per request
      instead of only matching "OAuth token has expired" text.
    - Retry request once after refresh with latest token from auth.json.
    - Add Claude CLI-style headers on refresh token POST.
    - Make Ava token loading use Auth.getValidToken first so expired
      tokens refresh proactively before provider calls.
    - Add provider unit tests for 401 refresh decision helper.
    
    Task-Id: t-778

diff --git a/Omni/Agent/Provider.hs b/Omni/Agent/Provider.hs
index 7bbb4037..e9935286 100644
--- a/Omni/Agent/Provider.hs
+++ b/Omni/Agent/Provider.hs
@@ -184,6 +184,12 @@ test =
       Test.unit "isRetryable detects timeout" <| do
         isRetryableError "Request timed out" Test.@=? True
         isRetryableError "Connection timeout" Test.@=? True,
+      Test.unit "shouldRefreshAnthropicOAuthToken refreshes on 401" <| do
+        shouldRefreshAnthropicOAuthToken True 401 Test.@=? True
+        shouldRefreshAnthropicOAuthToken False 401 Test.@=? False,
+      Test.unit "shouldRefreshAnthropicOAuthToken ignores non-401" <| do
+        shouldRefreshAnthropicOAuthToken True 400 Test.@=? False
+        shouldRefreshAnthropicOAuthToken True 429 Test.@=? False,
       -- Failover tests
       Test.unit "failover uses first working provider" <| do
         p1 <- mockProvider [MockText "First provider"]
@@ -1403,7 +1409,7 @@ chatAnthropicOAuth cfg tools messages = do
   freshToken <- readFreshOAuthToken
   let token = fromMaybe (providerApiKey cfg) freshToken
       cfgWithFreshToken = cfg {providerApiKey = token}
-  chatAnthropicOAuthWithToken cfgWithFreshToken tools messages
+  chatAnthropicOAuthWithToken cfgWithFreshToken tools messages True
 
 -- | Read fresh OAuth token from auth.json
 readFreshOAuthToken :: IO (Maybe Text)
@@ -1426,8 +1432,8 @@ readFreshOAuthToken = do
     else pure Nothing
 
 -- | Internal: Chat with a specific token
-chatAnthropicOAuthWithToken :: ProviderConfig -> [ToolApi] -> [Message] -> IO (Either Text ChatResult)
-chatAnthropicOAuthWithToken cfg tools messages = do
+chatAnthropicOAuthWithToken :: ProviderConfig -> [ToolApi] -> [Message] -> Bool -> IO (Either Text ChatResult)
+chatAnthropicOAuthWithToken cfg tools messages allowRefresh = do
   let url = Text.unpack (providerBaseUrl cfg) <> "/v1/messages"
   req0 <- HTTP.parseRequest url
   let reqBase =
@@ -1506,19 +1512,26 @@ chatAnthropicOAuthWithToken cfg tools messages = do
       then parseAnthropicResponseOAuth respBody -- Use OAuth parser that maps tool names back
       else do
         let errText = TE.decodeUtf8 (BL.toStrict respBody)
-        -- Check for expired token error
-        if status == 401 && "OAuth token has expired" `Text.isInfixOf` errText
+        if shouldRefreshAnthropicOAuthToken allowRefresh status
           then do
-            putText "OAuth token expired, attempting refresh..."
+            putText "Anthropic OAuth request returned 401, attempting refresh..."
             refreshResult <- refreshAnthropicOAuthToken
             case refreshResult of
               Left refreshErr -> pure (Left ("Token refresh failed: " <> refreshErr))
               Right _newToken -> do
                 putText "Token refreshed, retrying request..."
-                -- Retry - chatAnthropicOAuth will read fresh token from file
-                chatAnthropicOAuth cfg tools messages
+                freshToken <- readFreshOAuthToken
+                let retriedCfg = cfg {providerApiKey = fromMaybe (providerApiKey cfg) freshToken}
+                chatAnthropicOAuthWithToken retriedCfg tools messages False
           else pure (Left ("HTTP error: " <> tshow status <> " - " <> errText))
 
+-- | Refresh once on Anthropic OAuth auth failures.
+-- Anthropic now often returns generic 401 auth errors (for expired/invalid tokens)
+-- instead of the older "OAuth token has expired" message.
+shouldRefreshAnthropicOAuthToken :: Bool -> Int -> Bool
+shouldRefreshAnthropicOAuthToken allowRefresh status =
+  allowRefresh && status == 401
+
 -- | Refresh Anthropic OAuth token using refresh_token from auth files
 refreshAnthropicOAuthToken :: IO (Either Text Text)
 refreshAnthropicOAuthToken = do
@@ -1557,6 +1570,10 @@ doRefresh authPath refreshToken = do
   let req =
         HTTP.setRequestMethod "POST"
           <| HTTP.setRequestHeader "Content-Type" ["application/json"]
+          <| HTTP.setRequestHeader "Accept" ["application/json"]
+          <| HTTP.setRequestHeader "User-Agent" ["claude-cli/2.1.2 (external, cli)"]
+          <| HTTP.setRequestHeader "x-app" ["cli"]
+          <| HTTP.setRequestHeader "anthropic-dangerous-direct-browser-access" ["true"]
           <| HTTP.setRequestBodyLBS (Aeson.encode body)
           <| req0
   result <- try @SomeException (HTTP.httpLBS req)
diff --git a/Omni/Ava/Telegram/Bot.hs b/Omni/Ava/Telegram/Bot.hs
index 37ea1827..b30e58a0 100644
--- a/Omni/Ava/Telegram/Bot.hs
+++ b/Omni/Ava/Telegram/Bot.hs
@@ -2531,7 +2531,7 @@ loadClaudeOAuthToken = do
   case envToken of
     Just t -> pure (Just (Text.pack t))
     Nothing -> do
-      -- Try agent's auth file first (~/.local/share/agent/auth.json)
+      -- Prefer Auth.getValidToken so expired Anthropic OAuth creds refresh automatically.
       agentToken <- loadFromAgentAuth
       case agentToken of
         Just t -> pure (Just t)
@@ -2539,6 +2539,14 @@ loadClaudeOAuthToken = do
   where
     loadFromAgentAuth :: IO (Maybe Text)
     loadFromAgentAuth = do
+      tokenResult <- Auth.getValidToken Auth.ProviderAnthropic
+      case tokenResult of
+        Right creds -> pure (Just (Auth.credAccessToken creds))
+        Left _ -> loadFromAgentAuthFile
+
+    -- Fallback: if refresh/check logic fails, still try raw access token from auth.json.
+    loadFromAgentAuthFile :: IO (Maybe Text)
+    loadFromAgentAuthFile = do
       dataDir <- Directory.getXdgDirectory Directory.XdgData "agent"
       let authPath = dataDir FilePath.</> "auth.json"
       exists <- Directory.doesFileExist authPath