feat(auth): headless OAuth with paste-code input
Runs the local callback server and the manual copy-code flow in parallel. Displays a real input field (with blinking cursor) for pasting the authorization code / redirect URL / code#state. Anthropic manual variant uses the console copy-code redirect URI to bypass localhost.
- commit
- 6b03aa33207bebe82ca668e1b2085ee8c9d8aceb
- author
- patriceckhart · · 2026-04-22 15:49 UTC
- parent
- 9cf2946
- signature
- unsigned
- refs
- tag: v0.0.94
5 files · +313 -15
M internal/agent/modes/interactive.go +48 -2
| @@ -866,6 +866,12 @@ func (i *Interactive) redraw() { | |||
| 866 | 866 | cursorCol = c | |
| 867 | 867 | } | |
| 868 | 868 | } | |
| 869 | + | if i.dialog.Active() { | |
| 870 | + | if r, c := i.dialog.CursorPos(cols); r >= 0 { | |
| 871 | + | cursorRow = len(visibleChat) + r | |
| 872 | + | cursorCol = c | |
| 873 | + | } | |
| 874 | + | } | |
| 869 | 875 | if i.extPanel.Active() { | |
| 870 | 876 | cursorRow = -1 | |
| 871 | 877 | cursorCol = 0 | |
| @@ -1087,6 +1093,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1087 | 1093 | if act.StartOAuth { | |
| 1088 | 1094 | i.startOAuthFlow(act.Provider) | |
| 1089 | 1095 | } | |
| 1096 | + | if act.StartManual { | |
| 1097 | + | i.startManualOAuthFlow(act.Provider) | |
| 1098 | + | } | |
| 1099 | + | if act.SubmitCode != "" { | |
| 1100 | + | i.submitManualOAuthCode(act.SubmitCode) | |
| 1101 | + | } | |
| 1090 | 1102 | return false | |
| 1091 | 1103 | } | |
| 1092 | 1104 | if i.modelDialog.Active() { | |
| @@ -1916,12 +1928,46 @@ func (i *Interactive) startAPIKeyFlow(provider string) { | |||
| 1916 | 1928 | } | |
| 1917 | 1929 | ||
| 1918 | 1930 | func (i *Interactive) startOAuthFlow(provider string) { | |
| 1919 | - | url, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 1931 | + | // Always run the manual/copy-code flow in parallel with the local | |
| 1932 | + | // callback server so headless environments (docker, SSH) can paste | |
| 1933 | + | // the authorization code directly without first pressing 'p'. | |
| 1934 | + | _, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 1920 | 1935 | if err != nil { | |
| 1921 | 1936 | i.dialog.ShowResult(false, err.Error()) | |
| 1922 | 1937 | return | |
| 1923 | 1938 | } | |
| 1924 | - | i.dialog.ShowWaiting(url) | |
| 1939 | + | manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 1940 | + | if mErr == nil { | |
| 1941 | + | i.dialog.ShowWaiting(manualURL) | |
| 1942 | + | } else { | |
| 1943 | + | i.dialog.ShowResult(false, mErr.Error()) | |
| 1944 | + | } | |
| 1945 | + | } | |
| 1946 | + | ||
| 1947 | + | func (i *Interactive) startManualOAuthFlow(provider string) { | |
| 1948 | + | if i.cfg.AuthManager == nil { | |
| 1949 | + | return | |
| 1950 | + | } | |
| 1951 | + | i.cfg.AuthManager.CancelOAuth() | |
| 1952 | + | url, err := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 1953 | + | if err != nil { | |
| 1954 | + | i.dialog.ShowResult(false, err.Error()) | |
| 1955 | + | return | |
| 1956 | + | } | |
| 1957 | + | i.dialog.url = url | |
| 1958 | + | i.invalidate() | |
| 1959 | + | } | |
| 1960 | + | ||
| 1961 | + | func (i *Interactive) submitManualOAuthCode(code string) { | |
| 1962 | + | if i.cfg.AuthManager == nil { | |
| 1963 | + | return | |
| 1964 | + | } | |
| 1965 | + | go func() { | |
| 1966 | + | if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { | |
| 1967 | + | i.dialog.ShowResult(false, err.Error()) | |
| 1968 | + | i.invalidate() | |
| 1969 | + | } | |
| 1970 | + | }() | |
| 1925 | 1971 | } | |
| 1926 | 1972 | ||
| 1927 | 1973 | // applyModelSelection switches the active model (and provider, if the | |
| @@ -866,6 +866,12 @@ func (i *Interactive) redraw() { | |||||
| 866 | cursorCol = c | 866 | cursorCol = c | ||
| 867 | } | 867 | } | ||
| 868 | } | 868 | } | ||
| 869 | + | if i.dialog.Active() { | |||
| 870 | + | if r, c := i.dialog.CursorPos(cols); r >= 0 { | |||
| 871 | + | cursorRow = len(visibleChat) + r | |||
| 872 | + | cursorCol = c | |||
| 873 | + | } | |||
| 874 | + | } | |||
| 869 | if i.extPanel.Active() { | 875 | if i.extPanel.Active() { | ||
| 870 | cursorRow = -1 | 876 | cursorRow = -1 | ||
| 871 | cursorCol = 0 | 877 | cursorCol = 0 | ||
| @@ -1087,6 +1093,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1087 | if act.StartOAuth { | 1093 | if act.StartOAuth { | ||
| 1088 | i.startOAuthFlow(act.Provider) | 1094 | i.startOAuthFlow(act.Provider) | ||
| 1089 | } | 1095 | } | ||
| 1096 | + | if act.StartManual { | |||
| 1097 | + | i.startManualOAuthFlow(act.Provider) | |||
| 1098 | + | } | |||
| 1099 | + | if act.SubmitCode != "" { | |||
| 1100 | + | i.submitManualOAuthCode(act.SubmitCode) | |||
| 1101 | + | } | |||
| 1090 | return false | 1102 | return false | ||
| 1091 | } | 1103 | } | ||
| 1092 | if i.modelDialog.Active() { | 1104 | if i.modelDialog.Active() { | ||
| @@ -1916,12 +1928,46 @@ func (i *Interactive) startAPIKeyFlow(provider string) { | |||||
| 1916 | } | 1928 | } | ||
| 1917 | 1929 | ||||
| 1918 | func (i *Interactive) startOAuthFlow(provider string) { | 1930 | func (i *Interactive) startOAuthFlow(provider string) { | ||
| 1919 | - | url, err := i.cfg.AuthManager.StartOAuth(provider) | 1931 | + | // Always run the manual/copy-code flow in parallel with the local |
| 1932 | + | // callback server so headless environments (docker, SSH) can paste | |||
| 1933 | + | // the authorization code directly without first pressing 'p'. | |||
| 1934 | + | _, err := i.cfg.AuthManager.StartOAuth(provider) | |||
| 1920 | if err != nil { | 1935 | if err != nil { | ||
| 1921 | i.dialog.ShowResult(false, err.Error()) | 1936 | i.dialog.ShowResult(false, err.Error()) | ||
| 1922 | return | 1937 | return | ||
| 1923 | } | 1938 | } | ||
| 1924 | - | i.dialog.ShowWaiting(url) | 1939 | + | manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) |
| 1940 | + | if mErr == nil { | |||
| 1941 | + | i.dialog.ShowWaiting(manualURL) | |||
| 1942 | + | } else { | |||
| 1943 | + | i.dialog.ShowResult(false, mErr.Error()) | |||
| 1944 | + | } | |||
| 1945 | + | } | |||
| 1946 | + | ||||
| 1947 | + | func (i *Interactive) startManualOAuthFlow(provider string) { | |||
| 1948 | + | if i.cfg.AuthManager == nil { | |||
| 1949 | + | return | |||
| 1950 | + | } | |||
| 1951 | + | i.cfg.AuthManager.CancelOAuth() | |||
| 1952 | + | url, err := i.cfg.AuthManager.StartManualOAuth(provider) | |||
| 1953 | + | if err != nil { | |||
| 1954 | + | i.dialog.ShowResult(false, err.Error()) | |||
| 1955 | + | return | |||
| 1956 | + | } | |||
| 1957 | + | i.dialog.url = url | |||
| 1958 | + | i.invalidate() | |||
| 1959 | + | } | |||
| 1960 | + | ||||
| 1961 | + | func (i *Interactive) submitManualOAuthCode(code string) { | |||
| 1962 | + | if i.cfg.AuthManager == nil { | |||
| 1963 | + | return | |||
| 1964 | + | } | |||
| 1965 | + | go func() { | |||
| 1966 | + | if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { | |||
| 1967 | + | i.dialog.ShowResult(false, err.Error()) | |||
| 1968 | + | i.invalidate() | |||
| 1969 | + | } | |||
| 1970 | + | }() | |||
| 1925 | } | 1971 | } | ||
| 1926 | 1972 | ||||
| 1927 | // applyModelSelection switches the active model (and provider, if the | 1973 | // applyModelSelection switches the active model (and provider, if the | ||
M internal/agent/modes/login_dialog.go +105 -12
| @@ -15,11 +15,12 @@ type loginStep int | |||
| 15 | 15 | // dialog must default to closed so nothing shows up until Open() is | |
| 16 | 16 | // explicitly called. | |
| 17 | 17 | const ( | |
| 18 | - | loginStepClosed loginStep = iota | |
| 19 | - | loginStepMethod // pick apikey vs subscription | |
| 20 | - | loginStepProvider // pick anthropic vs openai | |
| 21 | - | loginStepWaiting // browser open, waiting for callback | |
| 22 | - | loginStepDone // success or error, waiting for key to dismiss | |
| 18 | + | loginStepClosed loginStep = iota | |
| 19 | + | loginStepMethod // pick apikey vs subscription | |
| 20 | + | loginStepProvider // pick anthropic vs openai | |
| 21 | + | loginStepWaiting // browser open, waiting for callback | |
| 22 | + | loginStepPasteCode // user pastes the auth code here | |
| 23 | + | loginStepDone // success or error, waiting for key to dismiss | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| 25 | 26 | // loginDialog is a tiny inline dialog rendered above the editor while | |
| @@ -32,6 +33,7 @@ type loginDialog struct { | |||
| 32 | 33 | success bool | |
| 33 | 34 | url string | |
| 34 | 35 | cursor int | |
| 36 | + | codeEd *tui.Editor | |
| 35 | 37 | ||
| 36 | 38 | // status is a snapshot of the current login state for each | |
| 37 | 39 | // provider, captured when Open() runs. Rendered above the | |
| @@ -135,10 +137,47 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 135 | 137 | lines = append(lines, frameRule(th, width)) | |
| 136 | 138 | case loginStepWaiting: | |
| 137 | 139 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width)) | |
| 138 | - | lines = append(lines, th.FG256(th.FG, "opening browser...")) | |
| 139 | - | lines = append(lines, th.FG256(th.Muted, d.url)) | |
| 140 | + | lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:")) | |
| 141 | + | wrapW := width - 2 | |
| 142 | + | if wrapW < 20 { | |
| 143 | + | wrapW = 20 | |
| 144 | + | } | |
| 145 | + | for _, seg := range tui.WrapANSILine(d.url, wrapW) { | |
| 146 | + | lines = append(lines, th.FG256(th.Accent, seg)) | |
| 147 | + | } | |
| 148 | + | lines = append(lines, "") | |
| 149 | + | lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) | |
| 150 | + | if d.codeEd == nil { | |
| 151 | + | d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) | |
| 152 | + | } | |
| 153 | + | edLines, _, _ := d.codeEd.Render(width - 2) | |
| 154 | + | for _, l := range edLines { | |
| 155 | + | lines = append(lines, l) | |
| 156 | + | } | |
| 157 | + | lines = append(lines, "") | |
| 158 | + | lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background")) | |
| 159 | + | lines = append(lines, frameRule(th, width)) | |
| 160 | + | case loginStepPasteCode: | |
| 161 | + | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider+" - paste code", width)) | |
| 162 | + | lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:")) | |
| 163 | + | wrapW := width - 2 | |
| 164 | + | if wrapW < 20 { | |
| 165 | + | wrapW = 20 | |
| 166 | + | } | |
| 167 | + | for _, seg := range tui.WrapANSILine(d.url, wrapW) { | |
| 168 | + | lines = append(lines, th.FG256(th.Accent, seg)) | |
| 169 | + | } | |
| 170 | + | lines = append(lines, "") | |
| 171 | + | lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) | |
| 172 | + | if d.codeEd == nil { | |
| 173 | + | d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) | |
| 174 | + | } | |
| 175 | + | edLines, _, _ := d.codeEd.Render(width - 2) | |
| 176 | + | for _, l := range edLines { | |
| 177 | + | lines = append(lines, l) | |
| 178 | + | } | |
| 140 | 179 | lines = append(lines, "") | |
| 141 | - | lines = append(lines, th.FG256(th.Muted, "waiting for callback. press esc to cancel.")) | |
| 180 | + | lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels")) | |
| 142 | 181 | lines = append(lines, frameRule(th, width)) | |
| 143 | 182 | case loginStepDone: | |
| 144 | 183 | title := "login - failed" | |
| @@ -197,8 +236,10 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 197 | 236 | type loginDialogAction struct { | |
| 198 | 237 | StartAPIKey bool | |
| 199 | 238 | StartOAuth bool | |
| 239 | + | StartManual bool | |
| 200 | 240 | Provider string | |
| 201 | 241 | Close bool | |
| 242 | + | SubmitCode string | |
| 202 | 243 | } | |
| 203 | 244 | ||
| 204 | 245 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| @@ -209,10 +250,9 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||
| 209 | 250 | case loginStepProvider: | |
| 210 | 251 | return d.handleProviderKey(k) | |
| 211 | 252 | case loginStepWaiting: | |
| 212 | - | if k.Kind == tui.KeyEsc { | |
| 213 | - | d.Close() | |
| 214 | - | return loginDialogAction{Close: true} | |
| 215 | - | } | |
| 253 | + | return d.handleWaitingKey(k) | |
| 254 | + | case loginStepPasteCode: | |
| 255 | + | return d.handlePasteCodeKey(k) | |
| 216 | 256 | case loginStepDone: | |
| 217 | 257 | d.Close() | |
| 218 | 258 | return loginDialogAction{Close: true} | |
| @@ -292,6 +332,59 @@ func (d *loginDialog) ShowResult(success bool, message string) { | |||
| 292 | 332 | d.message = message | |
| 293 | 333 | } | |
| 294 | 334 | ||
| 335 | + | func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |
| 336 | + | if k.Kind == tui.KeyEsc { | |
| 337 | + | d.Close() | |
| 338 | + | return loginDialogAction{Close: true} | |
| 339 | + | } | |
| 340 | + | if d.codeEd == nil { | |
| 341 | + | return loginDialogAction{} | |
| 342 | + | } | |
| 343 | + | if submit := d.codeEd.HandleKey(k); submit { | |
| 344 | + | code := d.codeEd.SubmitValue() | |
| 345 | + | d.codeEd.Clear() | |
| 346 | + | return loginDialogAction{SubmitCode: code} | |
| 347 | + | } | |
| 348 | + | return loginDialogAction{} | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { | |
| 352 | + | if k.Kind == tui.KeyEsc { | |
| 353 | + | d.Close() | |
| 354 | + | return loginDialogAction{Close: true} | |
| 355 | + | } | |
| 356 | + | if d.codeEd == nil { | |
| 357 | + | return loginDialogAction{} | |
| 358 | + | } | |
| 359 | + | if submit := d.codeEd.HandleKey(k); submit { | |
| 360 | + | code := d.codeEd.SubmitValue() | |
| 361 | + | d.codeEd.Clear() | |
| 362 | + | return loginDialogAction{SubmitCode: code} | |
| 363 | + | } | |
| 364 | + | return loginDialogAction{} | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // CursorPos returns the absolute row/col inside the dialog where the | |
| 368 | + | // terminal cursor should sit (paste-code step). Returns -1, -1 if the | |
| 369 | + | // dialog is not in an input-expecting state. The host uses this to | |
| 370 | + | // place the real blinking cursor on the code input. | |
| 371 | + | func (d *loginDialog) CursorPos(width int) (row, col int) { | |
| 372 | + | if d.codeEd == nil { | |
| 373 | + | return -1, -1 | |
| 374 | + | } | |
| 375 | + | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | |
| 376 | + | return -1, -1 | |
| 377 | + | } | |
| 378 | + | _, eRow, eCol := d.codeEd.Render(width - 2) | |
| 379 | + | wrapW := width - 2 | |
| 380 | + | if wrapW < 20 { | |
| 381 | + | wrapW = 20 | |
| 382 | + | } | |
| 383 | + | urlLines := len(tui.WrapANSILine(d.url, wrapW)) | |
| 384 | + | baseOffset := 1 /*frameHeader*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/ | |
| 385 | + | return baseOffset + eRow, eCol | |
| 386 | + | } | |
| 387 | + | ||
| 295 | 388 | func max0(x int) int { | |
| 296 | 389 | if x < 0 { | |
| 297 | 390 | return 0 | |
| @@ -15,11 +15,12 @@ type loginStep int | |||||
| 15 | // dialog must default to closed so nothing shows up until Open() is | 15 | // dialog must default to closed so nothing shows up until Open() is | ||
| 16 | // explicitly called. | 16 | // explicitly called. | ||
| 17 | const ( | 17 | const ( | ||
| 18 | - | loginStepClosed loginStep = iota | 18 | + | loginStepClosed loginStep = iota |
| 19 | - | loginStepMethod // pick apikey vs subscription | 19 | + | loginStepMethod // pick apikey vs subscription |
| 20 | - | loginStepProvider // pick anthropic vs openai | 20 | + | loginStepProvider // pick anthropic vs openai |
| 21 | - | loginStepWaiting // browser open, waiting for callback | 21 | + | loginStepWaiting // browser open, waiting for callback |
| 22 | - | loginStepDone // success or error, waiting for key to dismiss | 22 | + | loginStepPasteCode // user pastes the auth code here |
| 23 | + | loginStepDone // success or error, waiting for key to dismiss | |||
| 23 | ) | 24 | ) | ||
| 24 | 25 | ||||
| 25 | // loginDialog is a tiny inline dialog rendered above the editor while | 26 | // loginDialog is a tiny inline dialog rendered above the editor while | ||
| @@ -32,6 +33,7 @@ type loginDialog struct { | |||||
| 32 | success bool | 33 | success bool | ||
| 33 | url string | 34 | url string | ||
| 34 | cursor int | 35 | cursor int | ||
| 36 | + | codeEd *tui.Editor | |||
| 35 | 37 | ||||
| 36 | // status is a snapshot of the current login state for each | 38 | // status is a snapshot of the current login state for each | ||
| 37 | // provider, captured when Open() runs. Rendered above the | 39 | // provider, captured when Open() runs. Rendered above the | ||
| @@ -135,10 +137,47 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 135 | lines = append(lines, frameRule(th, width)) | 137 | lines = append(lines, frameRule(th, width)) | ||
| 136 | case loginStepWaiting: | 138 | case loginStepWaiting: | ||
| 137 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width)) | 139 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width)) | ||
| 138 | - | lines = append(lines, th.FG256(th.FG, "opening browser...")) | 140 | + | lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:")) |
| 139 | - | lines = append(lines, th.FG256(th.Muted, d.url)) | 141 | + | wrapW := width - 2 |
| 142 | + | if wrapW < 20 { | |||
| 143 | + | wrapW = 20 | |||
| 144 | + | } | |||
| 145 | + | for _, seg := range tui.WrapANSILine(d.url, wrapW) { | |||
| 146 | + | lines = append(lines, th.FG256(th.Accent, seg)) | |||
| 147 | + | } | |||
| 148 | + | lines = append(lines, "") | |||
| 149 | + | lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) | |||
| 150 | + | if d.codeEd == nil { | |||
| 151 | + | d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) | |||
| 152 | + | } | |||
| 153 | + | edLines, _, _ := d.codeEd.Render(width - 2) | |||
| 154 | + | for _, l := range edLines { | |||
| 155 | + | lines = append(lines, l) | |||
| 156 | + | } | |||
| 157 | + | lines = append(lines, "") | |||
| 158 | + | lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background")) | |||
| 159 | + | lines = append(lines, frameRule(th, width)) | |||
| 160 | + | case loginStepPasteCode: | |||
| 161 | + | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider+" - paste code", width)) | |||
| 162 | + | lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:")) | |||
| 163 | + | wrapW := width - 2 | |||
| 164 | + | if wrapW < 20 { | |||
| 165 | + | wrapW = 20 | |||
| 166 | + | } | |||
| 167 | + | for _, seg := range tui.WrapANSILine(d.url, wrapW) { | |||
| 168 | + | lines = append(lines, th.FG256(th.Accent, seg)) | |||
| 169 | + | } | |||
| 170 | + | lines = append(lines, "") | |||
| 171 | + | lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) | |||
| 172 | + | if d.codeEd == nil { | |||
| 173 | + | d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) | |||
| 174 | + | } | |||
| 175 | + | edLines, _, _ := d.codeEd.Render(width - 2) | |||
| 176 | + | for _, l := range edLines { | |||
| 177 | + | lines = append(lines, l) | |||
| 178 | + | } | |||
| 140 | lines = append(lines, "") | 179 | lines = append(lines, "") | ||
| 141 | - | lines = append(lines, th.FG256(th.Muted, "waiting for callback. press esc to cancel.")) | 180 | + | lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels")) |
| 142 | lines = append(lines, frameRule(th, width)) | 181 | lines = append(lines, frameRule(th, width)) | ||
| 143 | case loginStepDone: | 182 | case loginStepDone: | ||
| 144 | title := "login - failed" | 183 | title := "login - failed" | ||
| @@ -197,8 +236,10 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||||
| 197 | type loginDialogAction struct { | 236 | type loginDialogAction struct { | ||
| 198 | StartAPIKey bool | 237 | StartAPIKey bool | ||
| 199 | StartOAuth bool | 238 | StartOAuth bool | ||
| 239 | + | StartManual bool | |||
| 200 | Provider string | 240 | Provider string | ||
| 201 | Close bool | 241 | Close bool | ||
| 242 | + | SubmitCode string | |||
| 202 | } | 243 | } | ||
| 203 | 244 | ||||
| 204 | // HandleKey advances the dialog and returns an action to apply, if any. | 245 | // HandleKey advances the dialog and returns an action to apply, if any. | ||
| @@ -209,10 +250,9 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||||
| 209 | case loginStepProvider: | 250 | case loginStepProvider: | ||
| 210 | return d.handleProviderKey(k) | 251 | return d.handleProviderKey(k) | ||
| 211 | case loginStepWaiting: | 252 | case loginStepWaiting: | ||
| 212 | - | if k.Kind == tui.KeyEsc { | 253 | + | return d.handleWaitingKey(k) |
| 213 | - | d.Close() | 254 | + | case loginStepPasteCode: |
| 214 | - | return loginDialogAction{Close: true} | 255 | + | return d.handlePasteCodeKey(k) |
| 215 | - | } | |||
| 216 | case loginStepDone: | 256 | case loginStepDone: | ||
| 217 | d.Close() | 257 | d.Close() | ||
| 218 | return loginDialogAction{Close: true} | 258 | return loginDialogAction{Close: true} | ||
| @@ -292,6 +332,59 @@ func (d *loginDialog) ShowResult(success bool, message string) { | |||||
| 292 | d.message = message | 332 | d.message = message | ||
| 293 | } | 333 | } | ||
| 294 | 334 | ||||
| 335 | + | func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||
| 336 | + | if k.Kind == tui.KeyEsc { | |||
| 337 | + | d.Close() | |||
| 338 | + | return loginDialogAction{Close: true} | |||
| 339 | + | } | |||
| 340 | + | if d.codeEd == nil { | |||
| 341 | + | return loginDialogAction{} | |||
| 342 | + | } | |||
| 343 | + | if submit := d.codeEd.HandleKey(k); submit { | |||
| 344 | + | code := d.codeEd.SubmitValue() | |||
| 345 | + | d.codeEd.Clear() | |||
| 346 | + | return loginDialogAction{SubmitCode: code} | |||
| 347 | + | } | |||
| 348 | + | return loginDialogAction{} | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { | |||
| 352 | + | if k.Kind == tui.KeyEsc { | |||
| 353 | + | d.Close() | |||
| 354 | + | return loginDialogAction{Close: true} | |||
| 355 | + | } | |||
| 356 | + | if d.codeEd == nil { | |||
| 357 | + | return loginDialogAction{} | |||
| 358 | + | } | |||
| 359 | + | if submit := d.codeEd.HandleKey(k); submit { | |||
| 360 | + | code := d.codeEd.SubmitValue() | |||
| 361 | + | d.codeEd.Clear() | |||
| 362 | + | return loginDialogAction{SubmitCode: code} | |||
| 363 | + | } | |||
| 364 | + | return loginDialogAction{} | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // CursorPos returns the absolute row/col inside the dialog where the | |||
| 368 | + | // terminal cursor should sit (paste-code step). Returns -1, -1 if the | |||
| 369 | + | // dialog is not in an input-expecting state. The host uses this to | |||
| 370 | + | // place the real blinking cursor on the code input. | |||
| 371 | + | func (d *loginDialog) CursorPos(width int) (row, col int) { | |||
| 372 | + | if d.codeEd == nil { | |||
| 373 | + | return -1, -1 | |||
| 374 | + | } | |||
| 375 | + | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | |||
| 376 | + | return -1, -1 | |||
| 377 | + | } | |||
| 378 | + | _, eRow, eCol := d.codeEd.Render(width - 2) | |||
| 379 | + | wrapW := width - 2 | |||
| 380 | + | if wrapW < 20 { | |||
| 381 | + | wrapW = 20 | |||
| 382 | + | } | |||
| 383 | + | urlLines := len(tui.WrapANSILine(d.url, wrapW)) | |||
| 384 | + | baseOffset := 1 /*frameHeader*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/ | |||
| 385 | + | return baseOffset + eRow, eCol | |||
| 386 | + | } | |||
| 387 | + | ||||
| 295 | func max0(x int) int { | 388 | func max0(x int) int { | ||
| 296 | if x < 0 { | 389 | if x < 0 { | ||
| 297 | return 0 | 390 | return 0 | ||
M internal/auth/manager.go +131 -0
| @@ -3,8 +3,11 @@ package auth | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "net/url" | |
| 7 | + | "os" | |
| 6 | 8 | "os/exec" | |
| 7 | 9 | "runtime" | |
| 10 | + | "strings" | |
| 8 | 11 | "sync" | |
| 9 | 12 | "time" | |
| 10 | 13 | ) | |
| @@ -30,6 +33,10 @@ type Manager struct { | |||
| 30 | 33 | ||
| 31 | 34 | oauthCtx context.Context | |
| 32 | 35 | oauthCancel context.CancelFunc | |
| 36 | + | ||
| 37 | + | manualOp *OAuthProvider | |
| 38 | + | manualPKCE PKCE | |
| 39 | + | manualState string | |
| 33 | 40 | } | |
| 34 | 41 | ||
| 35 | 42 | // NewManager returns a Manager bound to store. | |
| @@ -196,6 +203,130 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||
| 196 | 203 | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |
| 197 | 204 | } | |
| 198 | 205 | ||
| 206 | + | // StartManualOAuth begins an OAuth flow but does NOT start a local | |
| 207 | + | // callback server or open a browser. The returned URL is shown to the | |
| 208 | + | // user so they can complete the authorization on another device; the | |
| 209 | + | // resulting code is pasted back via CompleteManualOAuth. | |
| 210 | + | func (m *Manager) StartManualOAuth(provider string) (string, error) { | |
| 211 | + | var op OAuthProvider | |
| 212 | + | switch provider { | |
| 213 | + | case "anthropic": | |
| 214 | + | op = AnthropicManualOAuth | |
| 215 | + | case "openai": | |
| 216 | + | op = OpenAIOAuth | |
| 217 | + | default: | |
| 218 | + | return "", fmt.Errorf("provider must be anthropic or openai") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | pkce, err := NewPKCE() | |
| 222 | + | if err != nil { | |
| 223 | + | return "", err | |
| 224 | + | } | |
| 225 | + | authURL, state, err := op.AuthorizeURL(pkce) | |
| 226 | + | if err != nil { | |
| 227 | + | return "", err | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | m.mu.Lock() | |
| 231 | + | m.manualOp = &op | |
| 232 | + | m.manualPKCE = pkce | |
| 233 | + | m.manualState = state | |
| 234 | + | m.mu.Unlock() | |
| 235 | + | ||
| 236 | + | m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) | |
| 237 | + | return authURL, nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // CompleteManualOAuth exchanges the user-pasted authorization code for | |
| 241 | + | // a token and stores it. Accepts either a raw code or a "code#state" | |
| 242 | + | // token shown by providers like Anthropic when code=true is set. | |
| 243 | + | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |
| 244 | + | m.mu.Lock() | |
| 245 | + | op := m.manualOp | |
| 246 | + | pkce := m.manualPKCE | |
| 247 | + | state := m.manualState | |
| 248 | + | m.mu.Unlock() | |
| 249 | + | if op == nil { | |
| 250 | + | return fmt.Errorf("no manual oauth flow in progress") | |
| 251 | + | } | |
| 252 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |
| 253 | + | if pastedState != "" { | |
| 254 | + | state = pastedState | |
| 255 | + | } | |
| 256 | + | if code == "" { | |
| 257 | + | return fmt.Errorf("empty code") | |
| 258 | + | } | |
| 259 | + | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | |
| 260 | + | defer cancel() | |
| 261 | + | tok, err := op.Exchange(exCtx, code, state, pkce) | |
| 262 | + | if err != nil { | |
| 263 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | |
| 267 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 268 | + | return err | |
| 269 | + | } | |
| 270 | + | m.mu.Lock() | |
| 271 | + | m.manualOp = nil | |
| 272 | + | m.manualPKCE = PKCE{} | |
| 273 | + | m.manualState = "" | |
| 274 | + | m.mu.Unlock() | |
| 275 | + | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |
| 276 | + | return nil | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // parseManualCodeInput accepts any of: | |
| 280 | + | // - a bare authorization code | |
| 281 | + | // - a "code#state" pair | |
| 282 | + | // - a full redirect URL like http(s)://host:port/callback?code=X&state=Y | |
| 283 | + | // | |
| 284 | + | // and returns the extracted code and (if any) state. | |
| 285 | + | func parseManualCodeInput(s string) (code, state string) { | |
| 286 | + | if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { | |
| 287 | + | if u, err := url.Parse(s); err == nil { | |
| 288 | + | q := u.Query() | |
| 289 | + | return q.Get("code"), q.Get("state") | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | if idx := strings.IndexByte(s, '#'); idx >= 0 { | |
| 293 | + | return s[:idx], s[idx+1:] | |
| 294 | + | } | |
| 295 | + | return s, "" | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // HasBrowser reports whether the current environment probably has a | |
| 299 | + | // working interactive browser reachable from localhost. Used by the | |
| 300 | + | // login flow to auto-switch to paste-code mode on headless boxes | |
| 301 | + | // (containers, SSH without display forwarding, etc.) instead of | |
| 302 | + | // trying to bind a callback port the user can never reach. | |
| 303 | + | func HasBrowser() bool { | |
| 304 | + | if os.Getenv("ZOT_NO_BROWSER") != "" { | |
| 305 | + | return false | |
| 306 | + | } | |
| 307 | + | if os.Getenv("ZOT_FORCE_BROWSER") != "" { | |
| 308 | + | return true | |
| 309 | + | } | |
| 310 | + | if _, err := os.Stat("/.dockerenv"); err == nil { | |
| 311 | + | return false | |
| 312 | + | } | |
| 313 | + | if b, err := os.ReadFile("/proc/1/cgroup"); err == nil { | |
| 314 | + | txt := string(b) | |
| 315 | + | if strings.Contains(txt, "docker") || strings.Contains(txt, "kubepods") || strings.Contains(txt, "containerd") { | |
| 316 | + | return false | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | switch runtime.GOOS { | |
| 320 | + | case "darwin", "windows": | |
| 321 | + | return true | |
| 322 | + | default: | |
| 323 | + | if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { | |
| 324 | + | return false | |
| 325 | + | } | |
| 326 | + | return true | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 199 | 330 | // CancelOAuth aborts any in-flight OAuth flow. | |
| 200 | 331 | func (m *Manager) CancelOAuth() { | |
| 201 | 332 | m.mu.Lock() | |
| @@ -3,8 +3,11 @@ package auth | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net/url" | |||
| 7 | + | "os" | |||
| 6 | "os/exec" | 8 | "os/exec" | ||
| 7 | "runtime" | 9 | "runtime" | ||
| 10 | + | "strings" | |||
| 8 | "sync" | 11 | "sync" | ||
| 9 | "time" | 12 | "time" | ||
| 10 | ) | 13 | ) | ||
| @@ -30,6 +33,10 @@ type Manager struct { | |||||
| 30 | 33 | ||||
| 31 | oauthCtx context.Context | 34 | oauthCtx context.Context | ||
| 32 | oauthCancel context.CancelFunc | 35 | oauthCancel context.CancelFunc | ||
| 36 | + | ||||
| 37 | + | manualOp *OAuthProvider | |||
| 38 | + | manualPKCE PKCE | |||
| 39 | + | manualState string | |||
| 33 | } | 40 | } | ||
| 34 | 41 | ||||
| 35 | // NewManager returns a Manager bound to store. | 42 | // NewManager returns a Manager bound to store. | ||
| @@ -196,6 +203,130 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||||
| 196 | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | 203 | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | ||
| 197 | } | 204 | } | ||
| 198 | 205 | ||||
| 206 | + | // StartManualOAuth begins an OAuth flow but does NOT start a local | |||
| 207 | + | // callback server or open a browser. The returned URL is shown to the | |||
| 208 | + | // user so they can complete the authorization on another device; the | |||
| 209 | + | // resulting code is pasted back via CompleteManualOAuth. | |||
| 210 | + | func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 211 | + | var op OAuthProvider | |||
| 212 | + | switch provider { | |||
| 213 | + | case "anthropic": | |||
| 214 | + | op = AnthropicManualOAuth | |||
| 215 | + | case "openai": | |||
| 216 | + | op = OpenAIOAuth | |||
| 217 | + | default: | |||
| 218 | + | return "", fmt.Errorf("provider must be anthropic or openai") | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | pkce, err := NewPKCE() | |||
| 222 | + | if err != nil { | |||
| 223 | + | return "", err | |||
| 224 | + | } | |||
| 225 | + | authURL, state, err := op.AuthorizeURL(pkce) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return "", err | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | m.mu.Lock() | |||
| 231 | + | m.manualOp = &op | |||
| 232 | + | m.manualPKCE = pkce | |||
| 233 | + | m.manualState = state | |||
| 234 | + | m.mu.Unlock() | |||
| 235 | + | ||||
| 236 | + | m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) | |||
| 237 | + | return authURL, nil | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // CompleteManualOAuth exchanges the user-pasted authorization code for | |||
| 241 | + | // a token and stores it. Accepts either a raw code or a "code#state" | |||
| 242 | + | // token shown by providers like Anthropic when code=true is set. | |||
| 243 | + | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||
| 244 | + | m.mu.Lock() | |||
| 245 | + | op := m.manualOp | |||
| 246 | + | pkce := m.manualPKCE | |||
| 247 | + | state := m.manualState | |||
| 248 | + | m.mu.Unlock() | |||
| 249 | + | if op == nil { | |||
| 250 | + | return fmt.Errorf("no manual oauth flow in progress") | |||
| 251 | + | } | |||
| 252 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |||
| 253 | + | if pastedState != "" { | |||
| 254 | + | state = pastedState | |||
| 255 | + | } | |||
| 256 | + | if code == "" { | |||
| 257 | + | return fmt.Errorf("empty code") | |||
| 258 | + | } | |||
| 259 | + | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | |||
| 260 | + | defer cancel() | |||
| 261 | + | tok, err := op.Exchange(exCtx, code, state, pkce) | |||
| 262 | + | if err != nil { | |||
| 263 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |||
| 264 | + | return err | |||
| 265 | + | } | |||
| 266 | + | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | |||
| 267 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |||
| 268 | + | return err | |||
| 269 | + | } | |||
| 270 | + | m.mu.Lock() | |||
| 271 | + | m.manualOp = nil | |||
| 272 | + | m.manualPKCE = PKCE{} | |||
| 273 | + | m.manualState = "" | |||
| 274 | + | m.mu.Unlock() | |||
| 275 | + | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |||
| 276 | + | return nil | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // parseManualCodeInput accepts any of: | |||
| 280 | + | // - a bare authorization code | |||
| 281 | + | // - a "code#state" pair | |||
| 282 | + | // - a full redirect URL like http(s)://host:port/callback?code=X&state=Y | |||
| 283 | + | // | |||
| 284 | + | // and returns the extracted code and (if any) state. | |||
| 285 | + | func parseManualCodeInput(s string) (code, state string) { | |||
| 286 | + | if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { | |||
| 287 | + | if u, err := url.Parse(s); err == nil { | |||
| 288 | + | q := u.Query() | |||
| 289 | + | return q.Get("code"), q.Get("state") | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | if idx := strings.IndexByte(s, '#'); idx >= 0 { | |||
| 293 | + | return s[:idx], s[idx+1:] | |||
| 294 | + | } | |||
| 295 | + | return s, "" | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // HasBrowser reports whether the current environment probably has a | |||
| 299 | + | // working interactive browser reachable from localhost. Used by the | |||
| 300 | + | // login flow to auto-switch to paste-code mode on headless boxes | |||
| 301 | + | // (containers, SSH without display forwarding, etc.) instead of | |||
| 302 | + | // trying to bind a callback port the user can never reach. | |||
| 303 | + | func HasBrowser() bool { | |||
| 304 | + | if os.Getenv("ZOT_NO_BROWSER") != "" { | |||
| 305 | + | return false | |||
| 306 | + | } | |||
| 307 | + | if os.Getenv("ZOT_FORCE_BROWSER") != "" { | |||
| 308 | + | return true | |||
| 309 | + | } | |||
| 310 | + | if _, err := os.Stat("/.dockerenv"); err == nil { | |||
| 311 | + | return false | |||
| 312 | + | } | |||
| 313 | + | if b, err := os.ReadFile("/proc/1/cgroup"); err == nil { | |||
| 314 | + | txt := string(b) | |||
| 315 | + | if strings.Contains(txt, "docker") || strings.Contains(txt, "kubepods") || strings.Contains(txt, "containerd") { | |||
| 316 | + | return false | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | switch runtime.GOOS { | |||
| 320 | + | case "darwin", "windows": | |||
| 321 | + | return true | |||
| 322 | + | default: | |||
| 323 | + | if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { | |||
| 324 | + | return false | |||
| 325 | + | } | |||
| 326 | + | return true | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 199 | // CancelOAuth aborts any in-flight OAuth flow. | 330 | // CancelOAuth aborts any in-flight OAuth flow. | ||
| 200 | func (m *Manager) CancelOAuth() { | 331 | func (m *Manager) CancelOAuth() { | ||
| 201 | m.mu.Lock() | 332 | m.mu.Lock() | ||
M internal/auth/oauth.go +25 -1
| @@ -44,8 +44,13 @@ type OAuthProvider struct { | |||
| 44 | 44 | IncludeStateInTokenRequest bool | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | - | // RedirectURI returns the full redirect URI for this provider. | |
| 47 | + | // RedirectURI returns the full redirect URI for this provider. For the | |
| 48 | + | // manual variants (no local callback server) RedirectPath is already an | |
| 49 | + | // absolute https URL, in which case it's returned as-is. | |
| 48 | 50 | func (p OAuthProvider) RedirectURI() string { | |
| 51 | + | if p.RedirectHost == "" && (strings.HasPrefix(p.RedirectPath, "http://") || strings.HasPrefix(p.RedirectPath, "https://")) { | |
| 52 | + | return p.RedirectPath | |
| 53 | + | } | |
| 49 | 54 | return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath) | |
| 50 | 55 | } | |
| 51 | 56 | ||
| @@ -71,6 +76,25 @@ var ( | |||
| 71 | 76 | IncludeStateInTokenRequest: true, | |
| 72 | 77 | } | |
| 73 | 78 | ||
| 79 | + | // Anthropic manual / headless variant: redirects to Anthropic's | |
| 80 | + | // copy-code page instead of a local loopback port, used when there | |
| 81 | + | // is no browser on the machine running zot (e.g. inside a Docker | |
| 82 | + | // container or over plain SSH). | |
| 83 | + | AnthropicManualOAuth = OAuthProvider{ | |
| 84 | + | Name: "anthropic", | |
| 85 | + | AuthURL: "https://claude.ai/oauth/authorize", | |
| 86 | + | TokenURL: "https://console.anthropic.com/v1/oauth/token", | |
| 87 | + | ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", | |
| 88 | + | Scopes: []string{"org:create_api_key", "user:profile", "user:inference"}, | |
| 89 | + | RedirectPath: "https://console.anthropic.com/oauth/code/callback", | |
| 90 | + | ExtraAuthArgs: map[string]string{ | |
| 91 | + | "code": "true", | |
| 92 | + | }, | |
| 93 | + | TokenBodyJSON: true, | |
| 94 | + | StateEqualsVerifier: true, | |
| 95 | + | IncludeStateInTokenRequest: true, | |
| 96 | + | } | |
| 97 | + | ||
| 74 | 98 | // OpenAI ChatGPT subscription: used by Codex CLI. | |
| 75 | 99 | OpenAIOAuth = OAuthProvider{ | |
| 76 | 100 | Name: "openai", | |
| @@ -44,8 +44,13 @@ type OAuthProvider struct { | |||||
| 44 | IncludeStateInTokenRequest bool | 44 | IncludeStateInTokenRequest bool | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | - | // RedirectURI returns the full redirect URI for this provider. | 47 | + | // RedirectURI returns the full redirect URI for this provider. For the |
| 48 | + | // manual variants (no local callback server) RedirectPath is already an | |||
| 49 | + | // absolute https URL, in which case it's returned as-is. | |||
| 48 | func (p OAuthProvider) RedirectURI() string { | 50 | func (p OAuthProvider) RedirectURI() string { | ||
| 51 | + | if p.RedirectHost == "" && (strings.HasPrefix(p.RedirectPath, "http://") || strings.HasPrefix(p.RedirectPath, "https://")) { | |||
| 52 | + | return p.RedirectPath | |||
| 53 | + | } | |||
| 49 | return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath) | 54 | return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath) | ||
| 50 | } | 55 | } | ||
| 51 | 56 | ||||
| @@ -71,6 +76,25 @@ var ( | |||||
| 71 | IncludeStateInTokenRequest: true, | 76 | IncludeStateInTokenRequest: true, | ||
| 72 | } | 77 | } | ||
| 73 | 78 | ||||
| 79 | + | // Anthropic manual / headless variant: redirects to Anthropic's | |||
| 80 | + | // copy-code page instead of a local loopback port, used when there | |||
| 81 | + | // is no browser on the machine running zot (e.g. inside a Docker | |||
| 82 | + | // container or over plain SSH). | |||
| 83 | + | AnthropicManualOAuth = OAuthProvider{ | |||
| 84 | + | Name: "anthropic", | |||
| 85 | + | AuthURL: "https://claude.ai/oauth/authorize", | |||
| 86 | + | TokenURL: "https://console.anthropic.com/v1/oauth/token", | |||
| 87 | + | ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", | |||
| 88 | + | Scopes: []string{"org:create_api_key", "user:profile", "user:inference"}, | |||
| 89 | + | RedirectPath: "https://console.anthropic.com/oauth/code/callback", | |||
| 90 | + | ExtraAuthArgs: map[string]string{ | |||
| 91 | + | "code": "true", | |||
| 92 | + | }, | |||
| 93 | + | TokenBodyJSON: true, | |||
| 94 | + | StateEqualsVerifier: true, | |||
| 95 | + | IncludeStateInTokenRequest: true, | |||
| 96 | + | } | |||
| 97 | + | ||||
| 74 | // OpenAI ChatGPT subscription: used by Codex CLI. | 98 | // OpenAI ChatGPT subscription: used by Codex CLI. | ||
| 75 | OpenAIOAuth = OAuthProvider{ | 99 | OpenAIOAuth = OAuthProvider{ | ||
| 76 | Name: "openai", | 100 | Name: "openai", | ||
M internal/tui/editor.go +4 -0
| @@ -829,6 +829,10 @@ func stripANSI(s string) string { | |||
| 829 | 829 | return string(out) | |
| 830 | 830 | } | |
| 831 | 831 | ||
| 832 | + | // WrapANSILine is the exported form of wrapANSILine so other modes / | |
| 833 | + | // dialogs can reuse the same visible-width-aware wrap behavior. | |
| 834 | + | func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) } | |
| 835 | + | ||
| 832 | 836 | // wrapANSILine folds a string that may contain ANSI CSI escapes so that | |
| 833 | 837 | // the visible width of each line stays within limit. Breaks happen on | |
| 834 | 838 | // spaces when possible, falling back to mid-token splits for very long | |
| @@ -829,6 +829,10 @@ func stripANSI(s string) string { | |||||
| 829 | return string(out) | 829 | return string(out) | ||
| 830 | } | 830 | } | ||
| 831 | 831 | ||||
| 832 | + | // WrapANSILine is the exported form of wrapANSILine so other modes / | |||
| 833 | + | // dialogs can reuse the same visible-width-aware wrap behavior. | |||
| 834 | + | func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) } | |||
| 835 | + | ||||
| 832 | // wrapANSILine folds a string that may contain ANSI CSI escapes so that | 836 | // wrapANSILine folds a string that may contain ANSI CSI escapes so that | ||
| 833 | // the visible width of each line stays within limit. Breaks happen on | 837 | // the visible width of each line stays within limit. Breaks happen on | ||
| 834 | // spaces when possible, falling back to mid-token splits for very long | 838 | // spaces when possible, falling back to mid-token splits for very long | ||