Restore Anthropic manual OAuth as a separate login choice
- commit
- 1390feae213a1c1a207683c8d9fab90d9d83c888
- author
- patriceckhart · · 2026-09-26 10:50 UTC
- parent
- 852dd3a
- signature
- unsigned
4 files · +143 -28
M README.md +2 -3
| @@ -115,9 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods: | |||
| 115 | 115 | ||
| 116 | 116 | - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly. | |
| 117 | 117 | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow. | |
| 118 | - | - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to. | |
| 119 | - | - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | |
| 120 | - | - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |
| 118 | + | - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | |
| 119 | + | - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |
| 121 | 120 | - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | |
| 122 | 121 | - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | |
| 123 | 122 | - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. | |
| @@ -115,9 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods: | |||||
| 115 | 115 | ||||
| 116 | - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly. | 116 | - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly. | ||
| 117 | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow. | 117 | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow. | ||
| 118 | - | - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to. | 118 | + | - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. |
| 119 | - | - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | 119 | + | - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. |
| 120 | - | - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |||
| 121 | - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | 120 | - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | ||
| 122 | - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | 121 | - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | ||
| 123 | - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. | 122 | - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. | ||
M packages/agent/modes/interactive.go +1 -12
| @@ -5341,17 +5341,6 @@ func (i *Interactive) startOAuthFlow(provider string) { | |||
| 5341 | 5341 | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 5342 | 5342 | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 5343 | 5343 | } | |
| 5344 | - | // Device-code providers already support headless login and must only | |
| 5345 | - | // start one polling flow. | |
| 5346 | - | if provider == "kimi" || provider == "xai" || provider == "github-copilot" { | |
| 5347 | - | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 5348 | - | if err != nil { | |
| 5349 | - | i.dialog.ShowResult(false, err.Error()) | |
| 5350 | - | return | |
| 5351 | - | } | |
| 5352 | - | i.dialog.ShowWaiting(loginURL) | |
| 5353 | - | return | |
| 5354 | - | } | |
| 5355 | 5344 | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 5356 | 5345 | if err != nil { | |
| 5357 | 5346 | i.dialog.ShowResult(false, err.Error()) | |
| @@ -5370,7 +5359,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) { | |||
| 5370 | 5359 | i.dialog.ShowResult(false, err.Error()) | |
| 5371 | 5360 | return | |
| 5372 | 5361 | } | |
| 5373 | - | i.dialog.url = url | |
| 5362 | + | i.dialog.ShowPasteCode(url) | |
| 5374 | 5363 | i.invalidate() | |
| 5375 | 5364 | } | |
| 5376 | 5365 | ||
| @@ -5341,17 +5341,6 @@ func (i *Interactive) startOAuthFlow(provider string) { | |||||
| 5341 | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | 5341 | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | ||
| 5342 | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | 5342 | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | ||
| 5343 | } | 5343 | } | ||
| 5344 | - | // Device-code providers already support headless login and must only | |||
| 5345 | - | // start one polling flow. | |||
| 5346 | - | if provider == "kimi" || provider == "xai" || provider == "github-copilot" { | |||
| 5347 | - | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | |||
| 5348 | - | if err != nil { | |||
| 5349 | - | i.dialog.ShowResult(false, err.Error()) | |||
| 5350 | - | return | |||
| 5351 | - | } | |||
| 5352 | - | i.dialog.ShowWaiting(loginURL) | |||
| 5353 | - | return | |||
| 5354 | - | } | |||
| 5355 | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | 5344 | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | ||
| 5356 | if err != nil { | 5345 | if err != nil { | ||
| 5357 | i.dialog.ShowResult(false, err.Error()) | 5346 | i.dialog.ShowResult(false, err.Error()) | ||
| @@ -5370,7 +5359,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) { | |||||
| 5370 | i.dialog.ShowResult(false, err.Error()) | 5359 | i.dialog.ShowResult(false, err.Error()) | ||
| 5371 | return | 5360 | return | ||
| 5372 | } | 5361 | } | ||
| 5373 | - | i.dialog.url = url | 5362 | + | i.dialog.ShowPasteCode(url) |
| 5374 | i.invalidate() | 5363 | i.invalidate() | ||
| 5375 | } | 5364 | } | ||
| 5376 | 5365 | ||||
M packages/agent/modes/login_dialog.go +62 -12
| @@ -18,15 +18,16 @@ type loginStep int | |||
| 18 | 18 | // dialog must default to closed so nothing shows up until Open() is | |
| 19 | 19 | // explicitly called. | |
| 20 | 20 | const ( | |
| 21 | - | loginStepClosed loginStep = iota | |
| 22 | - | loginStepMethod // pick apikey vs subscription | |
| 23 | - | loginStepProvider // pick anthropic vs openai vs kimi | |
| 24 | - | loginStepLlamaURL // enter and validate llama.cpp router URL | |
| 25 | - | loginStepLlamaKey // enter optional llama.cpp API key | |
| 26 | - | loginStepWaiting // browser open, waiting for callback | |
| 27 | - | loginStepPasteCode // user pastes the auth code here | |
| 28 | - | loginStepInfo // informational setup guidance | |
| 29 | - | loginStepDone // success or error, waiting for key to dismiss | |
| 21 | + | loginStepClosed loginStep = iota | |
| 22 | + | loginStepMethod // pick apikey vs subscription | |
| 23 | + | loginStepProvider // pick anthropic vs openai vs kimi | |
| 24 | + | loginStepOAuthMethod // pick browser or copy-code for Anthropic | |
| 25 | + | loginStepLlamaURL // enter and validate llama.cpp router URL | |
| 26 | + | loginStepLlamaKey // enter optional llama.cpp API key | |
| 27 | + | loginStepWaiting // browser open, waiting for callback | |
| 28 | + | loginStepPasteCode // user pastes the auth code here | |
| 29 | + | loginStepInfo // informational setup guidance | |
| 30 | + | loginStepDone // success or error, waiting for key to dismiss | |
| 30 | 31 | ) | |
| 31 | 32 | ||
| 32 | 33 | const loginProviderPageSize = 8 | |
| @@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 79 | 80 | d.url = "" | |
| 80 | 81 | d.cursor = 0 | |
| 81 | 82 | d.providerQuery = "" | |
| 83 | + | d.codeEd = nil | |
| 82 | 84 | d.llamaEd = nil | |
| 83 | 85 | d.llamaURL = "" | |
| 84 | 86 | d.infoTitle = "" | |
| @@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 183 | 185 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | |
| 184 | 186 | } | |
| 185 | 187 | lines = append(lines, frameRule(th, width)) | |
| 188 | + | case loginStepOAuthMethod: | |
| 189 | + | lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width)) | |
| 190 | + | lines = append(lines, th.FG256(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):")) | |
| 191 | + | for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} { | |
| 192 | + | plain := " " + label | |
| 193 | + | if idx == d.cursor { | |
| 194 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 195 | + | } else { | |
| 196 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | lines = append(lines, frameRule(th, width)) | |
| 186 | 200 | case loginStepLlamaURL: | |
| 187 | 201 | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) | |
| 188 | 202 | lines = append(lines, th.FG256(th.Muted, "server URL:")) | |
| @@ -216,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 216 | 230 | lines = append(lines, th.FG256(th.Accent, seg)) | |
| 217 | 231 | } | |
| 218 | 232 | lines = append(lines, "") | |
| 219 | - | if d.provider == "openai-codex" || d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" { | |
| 233 | + | if d.method == "oauth" { | |
| 220 | 234 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | |
| 221 | 235 | lines = append(lines, frameRule(th, width)) | |
| 222 | 236 | break | |
| @@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||
| 447 | 461 | return d.handleMethodKey(k) | |
| 448 | 462 | case loginStepProvider: | |
| 449 | 463 | return d.handleProviderKey(k) | |
| 464 | + | case loginStepOAuthMethod: | |
| 465 | + | return d.handleOAuthMethodKey(k) | |
| 450 | 466 | case loginStepLlamaURL, loginStepLlamaKey: | |
| 451 | 467 | return d.handleLlamaKey(k) | |
| 452 | 468 | case loginStepWaiting: | |
| @@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 543 | 559 | d.message = "" | |
| 544 | 560 | return loginDialogAction{} | |
| 545 | 561 | } | |
| 562 | + | if d.method == "oauth" && d.provider == "anthropic" { | |
| 563 | + | d.step = loginStepOAuthMethod | |
| 564 | + | d.cursor = 0 | |
| 565 | + | return loginDialogAction{} | |
| 566 | + | } | |
| 546 | 567 | d.step = loginStepWaiting | |
| 547 | 568 | if d.method == "apikey" { | |
| 548 | 569 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| @@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 552 | 573 | return loginDialogAction{} | |
| 553 | 574 | } | |
| 554 | 575 | ||
| 576 | + | func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction { | |
| 577 | + | switch k.Kind { | |
| 578 | + | case tui.KeyUp: | |
| 579 | + | d.cursor = 0 | |
| 580 | + | case tui.KeyDown: | |
| 581 | + | d.cursor = 1 | |
| 582 | + | case tui.KeyEsc: | |
| 583 | + | d.step = loginStepProvider | |
| 584 | + | d.cursor = 0 | |
| 585 | + | case tui.KeyEnter: | |
| 586 | + | if d.cursor == 1 { | |
| 587 | + | d.step = loginStepPasteCode | |
| 588 | + | return loginDialogAction{StartManual: true, Provider: d.provider} | |
| 589 | + | } | |
| 590 | + | d.step = loginStepWaiting | |
| 591 | + | return loginDialogAction{StartOAuth: true, Provider: d.provider} | |
| 592 | + | } | |
| 593 | + | return loginDialogAction{} | |
| 594 | + | } | |
| 595 | + | ||
| 555 | 596 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |
| 556 | 597 | if k.Kind == tui.KeyEsc { | |
| 557 | 598 | d.Close() | |
| @@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) { | |||
| 597 | 638 | d.url = url | |
| 598 | 639 | } | |
| 599 | 640 | ||
| 641 | + | // ShowPasteCode displays the URL for a manual OAuth transaction. | |
| 642 | + | func (d *loginDialog) ShowPasteCode(url string) { | |
| 643 | + | if d.step == loginStepClosed { | |
| 644 | + | return | |
| 645 | + | } | |
| 646 | + | d.step = loginStepPasteCode | |
| 647 | + | d.url = url | |
| 648 | + | } | |
| 649 | + | ||
| 600 | 650 | // ShowInfo transitions to an informational setup dialog. | |
| 601 | 651 | // No-op if the user has already dismissed the dialog. | |
| 602 | 652 | func (d *loginDialog) ShowInfo(title string, lines []string) { | |
| @@ -624,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||
| 624 | 674 | d.Close() | |
| 625 | 675 | return loginDialogAction{Close: true} | |
| 626 | 676 | } | |
| 627 | - | if d.codeEd == nil { | |
| 677 | + | if d.method == "oauth" || d.codeEd == nil { | |
| 628 | 678 | return loginDialogAction{} | |
| 629 | 679 | } | |
| 630 | 680 | if submit := d.codeEd.HandleKey(k); submit { | |
| @@ -667,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) { | |||
| 667 | 717 | } | |
| 668 | 718 | return baseOffset + eRow, eCol | |
| 669 | 719 | } | |
| 670 | - | if d.codeEd == nil { | |
| 720 | + | if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") { | |
| 671 | 721 | return -1, -1 | |
| 672 | 722 | } | |
| 673 | 723 | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | |
| @@ -18,15 +18,16 @@ type loginStep int | |||||
| 18 | // dialog must default to closed so nothing shows up until Open() is | 18 | // dialog must default to closed so nothing shows up until Open() is | ||
| 19 | // explicitly called. | 19 | // explicitly called. | ||
| 20 | const ( | 20 | const ( | ||
| 21 | - | loginStepClosed loginStep = iota | 21 | + | loginStepClosed loginStep = iota |
| 22 | - | loginStepMethod // pick apikey vs subscription | 22 | + | loginStepMethod // pick apikey vs subscription |
| 23 | - | loginStepProvider // pick anthropic vs openai vs kimi | 23 | + | loginStepProvider // pick anthropic vs openai vs kimi |
| 24 | - | loginStepLlamaURL // enter and validate llama.cpp router URL | 24 | + | loginStepOAuthMethod // pick browser or copy-code for Anthropic |
| 25 | - | loginStepLlamaKey // enter optional llama.cpp API key | 25 | + | loginStepLlamaURL // enter and validate llama.cpp router URL |
| 26 | - | loginStepWaiting // browser open, waiting for callback | 26 | + | loginStepLlamaKey // enter optional llama.cpp API key |
| 27 | - | loginStepPasteCode // user pastes the auth code here | 27 | + | loginStepWaiting // browser open, waiting for callback |
| 28 | - | loginStepInfo // informational setup guidance | 28 | + | loginStepPasteCode // user pastes the auth code here |
| 29 | - | loginStepDone // success or error, waiting for key to dismiss | 29 | + | loginStepInfo // informational setup guidance |
| 30 | + | loginStepDone // success or error, waiting for key to dismiss | |||
| 30 | ) | 31 | ) | ||
| 31 | 32 | ||||
| 32 | const loginProviderPageSize = 8 | 33 | const loginProviderPageSize = 8 | ||
| @@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 79 | d.url = "" | 80 | d.url = "" | ||
| 80 | d.cursor = 0 | 81 | d.cursor = 0 | ||
| 81 | d.providerQuery = "" | 82 | d.providerQuery = "" | ||
| 83 | + | d.codeEd = nil | |||
| 82 | d.llamaEd = nil | 84 | d.llamaEd = nil | ||
| 83 | d.llamaURL = "" | 85 | d.llamaURL = "" | ||
| 84 | d.infoTitle = "" | 86 | d.infoTitle = "" | ||
| @@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 183 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | 185 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | ||
| 184 | } | 186 | } | ||
| 185 | lines = append(lines, frameRule(th, width)) | 187 | lines = append(lines, frameRule(th, width)) | ||
| 188 | + | case loginStepOAuthMethod: | |||
| 189 | + | lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width)) | |||
| 190 | + | lines = append(lines, th.FG256(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):")) | |||
| 191 | + | for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} { | |||
| 192 | + | plain := " " + label | |||
| 193 | + | if idx == d.cursor { | |||
| 194 | + | lines = append(lines, th.PadHighlight(plain, width)) | |||
| 195 | + | } else { | |||
| 196 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | lines = append(lines, frameRule(th, width)) | |||
| 186 | case loginStepLlamaURL: | 200 | case loginStepLlamaURL: | ||
| 187 | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) | 201 | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) | ||
| 188 | lines = append(lines, th.FG256(th.Muted, "server URL:")) | 202 | lines = append(lines, th.FG256(th.Muted, "server URL:")) | ||
| @@ -216,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 216 | lines = append(lines, th.FG256(th.Accent, seg)) | 230 | lines = append(lines, th.FG256(th.Accent, seg)) | ||
| 217 | } | 231 | } | ||
| 218 | lines = append(lines, "") | 232 | lines = append(lines, "") | ||
| 219 | - | if d.provider == "openai-codex" || d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" { | 233 | + | if d.method == "oauth" { |
| 220 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | 234 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | ||
| 221 | lines = append(lines, frameRule(th, width)) | 235 | lines = append(lines, frameRule(th, width)) | ||
| 222 | break | 236 | break | ||
| @@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||||
| 447 | return d.handleMethodKey(k) | 461 | return d.handleMethodKey(k) | ||
| 448 | case loginStepProvider: | 462 | case loginStepProvider: | ||
| 449 | return d.handleProviderKey(k) | 463 | return d.handleProviderKey(k) | ||
| 464 | + | case loginStepOAuthMethod: | |||
| 465 | + | return d.handleOAuthMethodKey(k) | |||
| 450 | case loginStepLlamaURL, loginStepLlamaKey: | 466 | case loginStepLlamaURL, loginStepLlamaKey: | ||
| 451 | return d.handleLlamaKey(k) | 467 | return d.handleLlamaKey(k) | ||
| 452 | case loginStepWaiting: | 468 | case loginStepWaiting: | ||
| @@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 543 | d.message = "" | 559 | d.message = "" | ||
| 544 | return loginDialogAction{} | 560 | return loginDialogAction{} | ||
| 545 | } | 561 | } | ||
| 562 | + | if d.method == "oauth" && d.provider == "anthropic" { | |||
| 563 | + | d.step = loginStepOAuthMethod | |||
| 564 | + | d.cursor = 0 | |||
| 565 | + | return loginDialogAction{} | |||
| 566 | + | } | |||
| 546 | d.step = loginStepWaiting | 567 | d.step = loginStepWaiting | ||
| 547 | if d.method == "apikey" { | 568 | if d.method == "apikey" { | ||
| 548 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | 569 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | ||
| @@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 552 | return loginDialogAction{} | 573 | return loginDialogAction{} | ||
| 553 | } | 574 | } | ||
| 554 | 575 | ||||
| 576 | + | func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction { | |||
| 577 | + | switch k.Kind { | |||
| 578 | + | case tui.KeyUp: | |||
| 579 | + | d.cursor = 0 | |||
| 580 | + | case tui.KeyDown: | |||
| 581 | + | d.cursor = 1 | |||
| 582 | + | case tui.KeyEsc: | |||
| 583 | + | d.step = loginStepProvider | |||
| 584 | + | d.cursor = 0 | |||
| 585 | + | case tui.KeyEnter: | |||
| 586 | + | if d.cursor == 1 { | |||
| 587 | + | d.step = loginStepPasteCode | |||
| 588 | + | return loginDialogAction{StartManual: true, Provider: d.provider} | |||
| 589 | + | } | |||
| 590 | + | d.step = loginStepWaiting | |||
| 591 | + | return loginDialogAction{StartOAuth: true, Provider: d.provider} | |||
| 592 | + | } | |||
| 593 | + | return loginDialogAction{} | |||
| 594 | + | } | |||
| 595 | + | ||||
| 555 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | 596 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | ||
| 556 | if k.Kind == tui.KeyEsc { | 597 | if k.Kind == tui.KeyEsc { | ||
| 557 | d.Close() | 598 | d.Close() | ||
| @@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) { | |||||
| 597 | d.url = url | 638 | d.url = url | ||
| 598 | } | 639 | } | ||
| 599 | 640 | ||||
| 641 | + | // ShowPasteCode displays the URL for a manual OAuth transaction. | |||
| 642 | + | func (d *loginDialog) ShowPasteCode(url string) { | |||
| 643 | + | if d.step == loginStepClosed { | |||
| 644 | + | return | |||
| 645 | + | } | |||
| 646 | + | d.step = loginStepPasteCode | |||
| 647 | + | d.url = url | |||
| 648 | + | } | |||
| 649 | + | ||||
| 600 | // ShowInfo transitions to an informational setup dialog. | 650 | // ShowInfo transitions to an informational setup dialog. | ||
| 601 | // No-op if the user has already dismissed the dialog. | 651 | // No-op if the user has already dismissed the dialog. | ||
| 602 | func (d *loginDialog) ShowInfo(title string, lines []string) { | 652 | func (d *loginDialog) ShowInfo(title string, lines []string) { | ||
| @@ -624,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||||
| 624 | d.Close() | 674 | d.Close() | ||
| 625 | return loginDialogAction{Close: true} | 675 | return loginDialogAction{Close: true} | ||
| 626 | } | 676 | } | ||
| 627 | - | if d.codeEd == nil { | 677 | + | if d.method == "oauth" || d.codeEd == nil { |
| 628 | return loginDialogAction{} | 678 | return loginDialogAction{} | ||
| 629 | } | 679 | } | ||
| 630 | if submit := d.codeEd.HandleKey(k); submit { | 680 | if submit := d.codeEd.HandleKey(k); submit { | ||
| @@ -667,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) { | |||||
| 667 | } | 717 | } | ||
| 668 | return baseOffset + eRow, eCol | 718 | return baseOffset + eRow, eCol | ||
| 669 | } | 719 | } | ||
| 670 | - | if d.codeEd == nil { | 720 | + | if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") { |
| 671 | return -1, -1 | 721 | return -1, -1 | ||
| 672 | } | 722 | } | ||
| 673 | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | 723 | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | ||
M packages/agent/modes/login_dialog_test.go +78 -1
| @@ -1,9 +1,13 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/url" | |
| 6 | + | "path/filepath" | |
| 4 | 7 | "strings" | |
| 5 | 8 | "testing" | |
| 6 | 9 | ||
| 10 | + | "github.com/patriceckhart/zot/packages/provider/auth" | |
| 7 | 11 | "github.com/patriceckhart/zot/packages/tui" | |
| 8 | 12 | ) | |
| 9 | 13 | ||
| @@ -85,12 +89,85 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) { | |||
| 85 | 89 | } | |
| 86 | 90 | } | |
| 87 | 91 | ||
| 92 | + | func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) { | |
| 93 | + | for _, tc := range []struct { | |
| 94 | + | name string | |
| 95 | + | choice int | |
| 96 | + | manual bool | |
| 97 | + | step loginStep | |
| 98 | + | }{ | |
| 99 | + | {name: "browser", choice: 0, step: loginStepWaiting}, | |
| 100 | + | {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode}, | |
| 101 | + | } { | |
| 102 | + | t.Run(tc.name, func(t *testing.T) { | |
| 103 | + | d := newLoginDialog() | |
| 104 | + | d.Open(t.TempDir()) | |
| 105 | + | d.method = "oauth" | |
| 106 | + | d.step = loginStepProvider | |
| 107 | + | for idx, p := range d.providerOptions() { | |
| 108 | + | if p == "anthropic" { | |
| 109 | + | d.cursor = idx | |
| 110 | + | break | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod { | |
| 114 | + | t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step) | |
| 115 | + | } | |
| 116 | + | for i := 0; i < tc.choice; i++ { | |
| 117 | + | d.HandleKey(tui.Key{Kind: tui.KeyDown}) | |
| 118 | + | } | |
| 119 | + | action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 120 | + | if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step { | |
| 121 | + | t.Fatalf("flow action=%+v step=%v", action, d.step) | |
| 122 | + | } | |
| 123 | + | if tc.manual { | |
| 124 | + | d.ShowPasteCode("https://example.com/oauth/authorize") | |
| 125 | + | } else { | |
| 126 | + | d.ShowWaiting("https://example.com/oauth/authorize") | |
| 127 | + | } | |
| 128 | + | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | |
| 129 | + | if strings.Contains(text, "paste the authorization code") != tc.manual { | |
| 130 | + | t.Fatalf("wrong input for %s flow: %s", tc.name, text) | |
| 131 | + | } | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) { | |
| 137 | + | manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 138 | + | t.Cleanup(manager.Close) | |
| 139 | + | i := NewInteractive(InteractiveConfig{AuthManager: manager}) | |
| 140 | + | i.dialog.Open(t.TempDir()) | |
| 141 | + | i.dialog.method = "oauth" | |
| 142 | + | i.dialog.provider = "anthropic" | |
| 143 | + | i.dialog.step = loginStepOAuthMethod | |
| 144 | + | i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown}) | |
| 145 | + | act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 146 | + | if !act.StartManual || act.Provider != "anthropic" { | |
| 147 | + | t.Fatalf("expected manual Anthropic transaction, got %+v", act) | |
| 148 | + | } | |
| 149 | + | i.startManualOAuthFlow(act.Provider) | |
| 150 | + | if i.dialog.step != loginStepPasteCode { | |
| 151 | + | t.Fatalf("step = %v, want paste code", i.dialog.step) | |
| 152 | + | } | |
| 153 | + | u, err := url.Parse(i.dialog.url) | |
| 154 | + | if err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() { | |
| 158 | + | t.Fatalf("redirect URI = %q, want copy-code redirect", got) | |
| 159 | + | } | |
| 160 | + | if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" { | |
| 161 | + | t.Fatalf("manual transaction not ready for code entry: %v", err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 88 | 165 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |
| 89 | 166 | d := newLoginDialog() | |
| 90 | 167 | d.Open(t.TempDir()) | |
| 91 | 168 | d.method = "oauth" | |
| 92 | 169 | d.provider = "anthropic" | |
| 93 | - | d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") | |
| 170 | + | d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") | |
| 94 | 171 | ||
| 95 | 172 | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | |
| 96 | 173 | row, _ := d.CursorPos(80) | |
| @@ -1,9 +1,13 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "net/url" | |||
| 6 | + | "path/filepath" | |||
| 4 | "strings" | 7 | "strings" | ||
| 5 | "testing" | 8 | "testing" | ||
| 6 | 9 | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/provider/auth" | |||
| 7 | "github.com/patriceckhart/zot/packages/tui" | 11 | "github.com/patriceckhart/zot/packages/tui" | ||
| 8 | ) | 12 | ) | ||
| 9 | 13 | ||||
| @@ -85,12 +89,85 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) { | |||||
| 85 | } | 89 | } | ||
| 86 | } | 90 | } | ||
| 87 | 91 | ||||
| 92 | + | func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) { | |||
| 93 | + | for _, tc := range []struct { | |||
| 94 | + | name string | |||
| 95 | + | choice int | |||
| 96 | + | manual bool | |||
| 97 | + | step loginStep | |||
| 98 | + | }{ | |||
| 99 | + | {name: "browser", choice: 0, step: loginStepWaiting}, | |||
| 100 | + | {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode}, | |||
| 101 | + | } { | |||
| 102 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 103 | + | d := newLoginDialog() | |||
| 104 | + | d.Open(t.TempDir()) | |||
| 105 | + | d.method = "oauth" | |||
| 106 | + | d.step = loginStepProvider | |||
| 107 | + | for idx, p := range d.providerOptions() { | |||
| 108 | + | if p == "anthropic" { | |||
| 109 | + | d.cursor = idx | |||
| 110 | + | break | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod { | |||
| 114 | + | t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step) | |||
| 115 | + | } | |||
| 116 | + | for i := 0; i < tc.choice; i++ { | |||
| 117 | + | d.HandleKey(tui.Key{Kind: tui.KeyDown}) | |||
| 118 | + | } | |||
| 119 | + | action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 120 | + | if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step { | |||
| 121 | + | t.Fatalf("flow action=%+v step=%v", action, d.step) | |||
| 122 | + | } | |||
| 123 | + | if tc.manual { | |||
| 124 | + | d.ShowPasteCode("https://example.com/oauth/authorize") | |||
| 125 | + | } else { | |||
| 126 | + | d.ShowWaiting("https://example.com/oauth/authorize") | |||
| 127 | + | } | |||
| 128 | + | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | |||
| 129 | + | if strings.Contains(text, "paste the authorization code") != tc.manual { | |||
| 130 | + | t.Fatalf("wrong input for %s flow: %s", tc.name, text) | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) { | |||
| 137 | + | manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |||
| 138 | + | t.Cleanup(manager.Close) | |||
| 139 | + | i := NewInteractive(InteractiveConfig{AuthManager: manager}) | |||
| 140 | + | i.dialog.Open(t.TempDir()) | |||
| 141 | + | i.dialog.method = "oauth" | |||
| 142 | + | i.dialog.provider = "anthropic" | |||
| 143 | + | i.dialog.step = loginStepOAuthMethod | |||
| 144 | + | i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown}) | |||
| 145 | + | act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 146 | + | if !act.StartManual || act.Provider != "anthropic" { | |||
| 147 | + | t.Fatalf("expected manual Anthropic transaction, got %+v", act) | |||
| 148 | + | } | |||
| 149 | + | i.startManualOAuthFlow(act.Provider) | |||
| 150 | + | if i.dialog.step != loginStepPasteCode { | |||
| 151 | + | t.Fatalf("step = %v, want paste code", i.dialog.step) | |||
| 152 | + | } | |||
| 153 | + | u, err := url.Parse(i.dialog.url) | |||
| 154 | + | if err != nil { | |||
| 155 | + | t.Fatal(err) | |||
| 156 | + | } | |||
| 157 | + | if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() { | |||
| 158 | + | t.Fatalf("redirect URI = %q, want copy-code redirect", got) | |||
| 159 | + | } | |||
| 160 | + | if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" { | |||
| 161 | + | t.Fatalf("manual transaction not ready for code entry: %v", err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 88 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | 165 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | ||
| 89 | d := newLoginDialog() | 166 | d := newLoginDialog() | ||
| 90 | d.Open(t.TempDir()) | 167 | d.Open(t.TempDir()) | ||
| 91 | d.method = "oauth" | 168 | d.method = "oauth" | ||
| 92 | d.provider = "anthropic" | 169 | d.provider = "anthropic" | ||
| 93 | - | d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") | 170 | + | d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") |
| 94 | 171 | ||||
| 95 | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | 172 | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | ||
| 96 | row, _ := d.CursorPos(80) | 173 | row, _ := d.CursorPos(80) | ||