Review Task

ID:t-777
Title:Fix Telegram Claude OAuth redirect URI encoding
Status:Review

Commit: 49f02885

commit 49f028850694dfc06949b1cf6b18d04147883de6
Author: Coder Agent <coder@agents.omni>
Date:   Sat Apr 11 18:31:23 2026

    agent auth: encode Anthropic OAuth redirect URI
    
    Fix Claude OAuth URL generation to use percent-encoded query params.
    
    Previously we built the Anthropic authorize URL by string concat.
    That left redirect_uri as raw https://... in the query string.
    On iOS Telegram /login flows, URL rewriting could mutate nested
    https:// to x-safari-https://, causing Anthropic to reject auth.
    
    Now Anthropic auth URL generation uses URI.renderSimpleQuery,
    matching the Codex flow. This keeps redirect_uri encoded.
    
    Also add a regression test to assert redirect_uri is encoded.
    
    Task-Id: t-777

diff --git a/Omni/Agent/Auth.hs b/Omni/Agent/Auth.hs
index e777a71c..2100d990 100644
--- a/Omni/Agent/Auth.hs
+++ b/Omni/Agent/Auth.hs
@@ -81,7 +81,11 @@ test =
       Test.unit "credentialsFile returns valid path" <| do
         path <- credentialsFile
         -- Should end with auth.json
-        FilePath.takeFileName path Test.@=? "auth.json"
+        FilePath.takeFileName path Test.@=? "auth.json",
+      Test.unit "Anthropic auth URL encodes redirect URI" <| do
+        let authUrl = buildAnthropicAuthUrl "state-test" "challenge-test"
+        Test.assertBool "redirect_uri should be percent-encoded" ("redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback" `Text.isInfixOf` authUrl)
+        Test.assertBool "redirect_uri should not be raw in query string" (not ("redirect_uri=https://console.anthropic.com/oauth/code/callback" `Text.isInfixOf` authUrl))
     ]
 
 -- | OAuth credentials stored on disk
@@ -139,7 +143,7 @@ redirectUri :: Text
 redirectUri = "https://console.anthropic.com/oauth/code/callback"
 
 scopes :: Text
-scopes = "org:create_api_key%20user:profile%20user:inference"
+scopes = "org:create_api_key user:profile user:inference"
 
 -- | OpenAI Codex OAuth constants
 codexClientId :: Text
@@ -218,11 +222,9 @@ base64UrlEncode bs = Text.pack <| go (BS.unpack bs)
           i3 = fromIntegral (c .&. 0x3F)
        in [idx i0, idx i1, idx i2, idx i3] ++ go rest
 
--- | Generate Anthropic OAuth URL for non-interactive flow (e.g., Telegram bot)
--- Returns (authUrl, verifier) - caller must store verifier to exchange code later
-generateAnthropicAuthUrl :: IO (Text, Text)
-generateAnthropicAuthUrl = do
-  (verifier, challenge) <- generatePKCE
+-- | Build Anthropic OAuth URL with correctly percent-encoded query params.
+buildAnthropicAuthUrl :: Text -> Text -> Text
+buildAnthropicAuthUrl authState challenge =
   let params =
         [ ("code", "true"),
           ("client_id", clientId),
@@ -231,10 +233,17 @@ generateAnthropicAuthUrl = do
           ("scope", scopes),
           ("code_challenge", challenge),
           ("code_challenge_method", "S256"),
-          ("state", verifier)
+          ("state", authState)
         ]
-      paramStr = Text.intercalate "&" <| map (\(k, v) -> k <> "=" <> v) params
-      authUrl = authorizeUrl <> "?" <> paramStr
+      query = URI.renderSimpleQuery False <| map (Bifunctor.bimap TE.encodeUtf8 TE.encodeUtf8) params
+   in authorizeUrl <> "?" <> TE.decodeUtf8 query
+
+-- | Generate Anthropic OAuth URL for non-interactive flow (e.g., Telegram bot)
+-- Returns (authUrl, verifier) - caller must store verifier to exchange code later
+generateAnthropicAuthUrl :: IO (Text, Text)
+generateAnthropicAuthUrl = do
+  (verifier, challenge) <- generatePKCE
+  let authUrl = buildAnthropicAuthUrl verifier challenge
   pure (authUrl, verifier)
 
 -- | Exchange Anthropic authorization code for tokens (non-interactive)
@@ -270,18 +279,7 @@ loginAnthropic = do
   (verifier, challenge) <- generatePKCE
 
   -- Build auth URL
-  let params =
-        [ ("code", "true"),
-          ("client_id", clientId),
-          ("response_type", "code"),
-          ("redirect_uri", redirectUri),
-          ("scope", scopes),
-          ("code_challenge", challenge),
-          ("code_challenge_method", "S256"),
-          ("state", verifier)
-        ]
-      paramStr = Text.intercalate "&" <| map (\(k, v) -> k <> "=" <> v) params
-      authUrl = authorizeUrl <> "?" <> paramStr
+  let authUrl = buildAnthropicAuthUrl verifier challenge
 
   -- Check if we can read from stdin (must be a terminal for interactive login)
   isTerm <- Terminal.queryTerminal PosixIO.stdInput