detached 6b03aa3

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() {
866866 cursorCol = c
867867 }
868868 }
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+ }
869875 if i.extPanel.Active() {
870876 cursorRow = -1
871877 cursorCol = 0
@@ -1087,6 +1093,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
10871093 if act.StartOAuth {
10881094 i.startOAuthFlow(act.Provider)
10891095 }
1096+ if act.StartManual {
1097+ i.startManualOAuthFlow(act.Provider)
1098+ }
1099+ if act.SubmitCode != "" {
1100+ i.submitManualOAuthCode(act.SubmitCode)
1101+ }
10901102 return false
10911103 }
10921104 if i.modelDialog.Active() {
@@ -1916,12 +1928,46 @@ func (i *Interactive) startAPIKeyFlow(provider string) {
19161928 }
19171929
19181930 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)
19201935 if err != nil {
19211936 i.dialog.ShowResult(false, err.Error())
19221937 return
19231938 }
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+ }()
19251971 }
19261972
19271973 // applyModelSelection switches the active model (and provider, if the
@@ -866,6 +866,12 @@ func (i *Interactive) redraw() {
866 cursorCol = c866 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 = -1876 cursorRow = -1
871 cursorCol = 0877 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 false1102 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 return1937 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 the1973 // 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
1515 // dialog must default to closed so nothing shows up until Open() is
1616 // explicitly called.
1717 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
2324 )
2425
2526 // loginDialog is a tiny inline dialog rendered above the editor while
@@ -32,6 +33,7 @@ type loginDialog struct {
3233 success bool
3334 url string
3435 cursor int
36+ codeEd *tui.Editor
3537
3638 // status is a snapshot of the current login state for each
3739 // provider, captured when Open() runs. Rendered above the
@@ -135,10 +137,47 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
135137 lines = append(lines, frameRule(th, width))
136138 case loginStepWaiting:
137139 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+ }
140179 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"))
142181 lines = append(lines, frameRule(th, width))
143182 case loginStepDone:
144183 title := "login - failed"
@@ -197,8 +236,10 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
197236 type loginDialogAction struct {
198237 StartAPIKey bool
199238 StartOAuth bool
239+ StartManual bool
200240 Provider string
201241 Close bool
242+ SubmitCode string
202243 }
203244
204245 // HandleKey advances the dialog and returns an action to apply, if any.
@@ -209,10 +250,9 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
209250 case loginStepProvider:
210251 return d.handleProviderKey(k)
211252 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)
216256 case loginStepDone:
217257 d.Close()
218258 return loginDialogAction{Close: true}
@@ -292,6 +332,59 @@ func (d *loginDialog) ShowResult(success bool, message string) {
292332 d.message = message
293333 }
294334
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+
295388 func max0(x int) int {
296389 if x < 0 {
297390 return 0
@@ -15,11 +15,12 @@ type loginStep int
15 // dialog must default to closed so nothing shows up until Open() is15 // 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 = iota18+ loginStepClosed loginStep = iota
19- loginStepMethod // pick apikey vs subscription19+ loginStepMethod // pick apikey vs subscription
20- loginStepProvider // pick anthropic vs openai20+ loginStepProvider // pick anthropic vs openai
21- loginStepWaiting // browser open, waiting for callback21+ loginStepWaiting // browser open, waiting for callback
22- loginStepDone // success or error, waiting for key to dismiss22+ 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 while26 // loginDialog is a tiny inline dialog rendered above the editor while
@@ -32,6 +33,7 @@ type loginDialog struct {
32 success bool33 success bool
33 url string34 url string
34 cursor int35 cursor int
36+ codeEd *tui.Editor
35 37
36 // status is a snapshot of the current login state for each38 // status is a snapshot of the current login state for each
37 // provider, captured when Open() runs. Rendered above the39 // 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 bool237 StartAPIKey bool
199 StartOAuth bool238 StartOAuth bool
239+ StartManual bool
200 Provider string240 Provider string
201 Close bool241 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 = message332 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 0390 return 0
M internal/auth/manager.go +131 -0
@@ -3,8 +3,11 @@ package auth
33 import (
44 "context"
55 "fmt"
6+ "net/url"
7+ "os"
68 "os/exec"
79 "runtime"
10+ "strings"
811 "sync"
912 "time"
1013 )
@@ -30,6 +33,10 @@ type Manager struct {
3033
3134 oauthCtx context.Context
3235 oauthCancel context.CancelFunc
36+
37+ manualOp *OAuthProvider
38+ manualPKCE PKCE
39+ manualState string
3340 }
3441
3542 // NewManager returns a Manager bound to store.
@@ -196,6 +203,130 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback
196203 m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
197204 }
198205
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+
199330 // CancelOAuth aborts any in-flight OAuth flow.
200331 func (m *Manager) CancelOAuth() {
201332 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.Context34 oauthCtx context.Context
32 oauthCancel context.CancelFunc35 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 {
4444 IncludeStateInTokenRequest bool
4545 }
4646
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.
4850 func (p OAuthProvider) RedirectURI() string {
51+ if p.RedirectHost == "" && (strings.HasPrefix(p.RedirectPath, "http://") || strings.HasPrefix(p.RedirectPath, "https://")) {
52+ return p.RedirectPath
53+ }
4954 return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath)
5055 }
5156
@@ -71,6 +76,25 @@ var (
7176 IncludeStateInTokenRequest: true,
7277 }
7378
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+
7498 // OpenAI ChatGPT subscription: used by Codex CLI.
7599 OpenAIOAuth = OAuthProvider{
76100 Name: "openai",
@@ -44,8 +44,13 @@ type OAuthProvider struct {
44 IncludeStateInTokenRequest bool44 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 {
829829 return string(out)
830830 }
831831
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+
832836 // wrapANSILine folds a string that may contain ANSI CSI escapes so that
833837 // the visible width of each line stays within limit. Breaks happen on
834838 // 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 that836 // wrapANSILine folds a string that may contain ANSI CSI escapes so that
833 // the visible width of each line stays within limit. Breaks happen on837 // the visible width of each line stays within limit. Breaks happen on
834 // spaces when possible, falling back to mid-token splits for very long838 // spaces when possible, falling back to mid-token splits for very long

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close