Skip to content

Commit ecb93fc

Browse files
greynewellclaude
andauthored
fix: print auth URL and prompt for token when browser open fails (#162)
* test: failing test for headless browser auth fallback (#155) * fix: fall back to URL+prompt when browser open fails in headless environments When the browser cannot be opened (headless/SSH/container environments), Login now prints the CLI auth URL (with port and state) so the user can visit it from another machine, then prompts them to paste their API key. Three package-level vars make the behaviour testable without exec or os coupling: openBrowserFunc, stdinReader, and loginOut. Closes #155. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 037157d commit ecb93fc

2 files changed

Lines changed: 99 additions & 21 deletions

File tree

internal/auth/handler.go

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"crypto/rand"
77
"encoding/hex"
88
"fmt"
9+
"io"
910
"net"
1011
"net/http"
1112
"os"
@@ -23,6 +24,18 @@ import (
2324

2425
const dashboardBase = "https://dashboard.supermodeltools.com"
2526

27+
// loginOut is the writer used for all Login output. Override in tests to
28+
// capture output without touching os.Stdout.
29+
var loginOut io.Writer = os.Stdout
30+
31+
// stdinReader is the reader used by readSecret in non-TTY mode. Override in
32+
// tests to supply canned input without touching os.Stdin.
33+
var stdinReader io.Reader = os.Stdin
34+
35+
// openBrowserFunc is the injectable browser-open function. Override in tests
36+
// to simulate headless environments where a browser cannot be launched.
37+
var openBrowserFunc = openBrowserDefault
38+
2639
// Login runs the browser-based login flow. Opens the dashboard to create an
2740
// API key, receives it via localhost callback, validates, and saves it.
2841
// Falls back to manual paste if the browser flow fails.
@@ -35,8 +48,8 @@ func Login(ctx context.Context) error {
3548
// Start localhost server on a random port.
3649
listener, err := net.Listen("tcp", "127.0.0.1:0")
3750
if err != nil {
38-
fmt.Fprintln(os.Stderr, "Could not start local server — falling back to manual login.")
39-
return loginManual(cfg)
51+
fmt.Fprintln(loginOut, "Could not start local server — falling back to manual login.")
52+
return loginManual(cfg, "")
4053
}
4154
port := listener.Addr().(*net.TCPAddr).Port
4255
state := randomState()
@@ -71,36 +84,36 @@ func Login(ctx context.Context) error {
7184

7285
// Build the dashboard URL and open the browser.
7386
authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", dashboardBase, port, state)
74-
fmt.Println("Opening browser to log in...")
75-
fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", authURL)
87+
fmt.Fprintln(loginOut, "Opening browser to log in...")
88+
fmt.Fprintf(loginOut, "If the browser doesn't open, visit:\n %s\n\n", authURL)
7689

77-
if err := openBrowser(authURL); err != nil {
78-
fmt.Fprintln(os.Stderr, "Could not open browser — falling back to manual login.")
90+
if err := openBrowserFunc(authURL); err != nil {
91+
fmt.Fprintln(loginOut, "Could not open browser — falling back to manual login.")
7992
srv.Close()
80-
return loginManual(cfg)
93+
return loginManual(cfg, authURL)
8194
}
8295

8396
// Wait for callback or timeout.
84-
fmt.Print("Waiting for authentication...")
97+
fmt.Fprint(loginOut, "Waiting for authentication...")
8598
select {
8699
case key := <-keyCh:
87-
fmt.Println()
100+
fmt.Fprintln(loginOut)
88101
cfg.APIKey = strings.TrimSpace(key)
89102
if err := cfg.Save(); err != nil {
90103
return err
91104
}
92105
ui.Success("Authenticated — key saved to %s", config.Path())
93106
return nil
94107
case err := <-errCh:
95-
fmt.Println()
108+
fmt.Fprintln(loginOut)
96109
return fmt.Errorf("local server error: %w", err)
97110
case <-time.After(5 * time.Minute):
98-
fmt.Println()
99-
fmt.Fprintln(os.Stderr, "Timed out waiting for browser login — falling back to manual login.")
111+
fmt.Fprintln(loginOut)
112+
fmt.Fprintln(loginOut, "Timed out waiting for browser login — falling back to manual login.")
100113
srv.Close()
101-
return loginManual(cfg)
114+
return loginManual(cfg, authURL)
102115
case <-ctx.Done():
103-
fmt.Println()
116+
fmt.Fprintln(loginOut)
104117
return ctx.Err()
105118
}
106119
}
@@ -141,10 +154,16 @@ func Logout(_ context.Context) error {
141154
return nil
142155
}
143156

144-
// loginManual is the fallback paste-based login.
145-
func loginManual(cfg *config.Config) error {
146-
fmt.Println("Get your API key at https://dashboard.supermodeltools.com/api-keys")
147-
fmt.Print("Paste your API key: ")
157+
// loginManual is the fallback paste-based login. When authURL is non-empty
158+
// (i.e. the browser-open step failed), it is printed so the user can visit it
159+
// from another machine or browser.
160+
func loginManual(cfg *config.Config, authURL string) error {
161+
if authURL != "" {
162+
fmt.Fprintf(loginOut, "Visit the following URL to get your API key:\n %s\n\n", authURL)
163+
} else {
164+
fmt.Fprintf(loginOut, "Get your API key at %s/api-keys\n", dashboardBase)
165+
}
166+
fmt.Fprint(loginOut, "Paste your API key: ")
148167

149168
key, err := readSecret()
150169
if err != nil {
@@ -163,7 +182,7 @@ func loginManual(cfg *config.Config) error {
163182
return nil
164183
}
165184

166-
func openBrowser(url string) error {
185+
func openBrowserDefault(url string) error {
167186
switch runtime.GOOS {
168187
case "darwin":
169188
return exec.Command("open", url).Start()
@@ -183,17 +202,18 @@ func randomState() string {
183202
}
184203

185204
// readSecret reads a line from stdin, suppressing echo when a TTY is attached.
205+
// In non-TTY mode it reads from stdinReader (injectable for tests).
186206
func readSecret() (string, error) {
187207
fd := int(syscall.Stdin) //nolint:unconvert // syscall.Stdin is uintptr on Windows
188208
if term.IsTerminal(fd) {
189209
b, err := term.ReadPassword(fd)
190-
fmt.Println()
210+
fmt.Fprintln(loginOut)
191211
if err != nil {
192212
return "", err
193213
}
194214
return string(b), nil
195215
}
196-
scanner := bufio.NewScanner(os.Stdin)
216+
scanner := bufio.NewScanner(stdinReader)
197217
if scanner.Scan() {
198218
return scanner.Text(), nil
199219
}

internal/auth/handler_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package auth
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"net"
78
"net/http"
89
"net/http/httptest"
910
"os"
1011
"path/filepath"
12+
"strings"
1113
"testing"
1214
"time"
1315

@@ -284,3 +286,59 @@ func TestLogout_SaveError(t *testing.T) {
284286
t.Error("expected error when cfg.Save fails during logout")
285287
}
286288
}
289+
290+
// TestLoginFallback_HeadlessBrowser verifies that when the browser cannot be
291+
// opened (headless/SSH/container environments), Login prints the auth URL to
292+
// stdout and falls back to prompting the user to paste an API key manually.
293+
func TestLoginFallback_HeadlessBrowser(t *testing.T) {
294+
tmp := t.TempDir()
295+
t.Setenv("HOME", tmp)
296+
t.Setenv("USERPROFILE", tmp)
297+
t.Setenv("SUPERMODEL_API_KEY", "")
298+
299+
// Override the injectable browser-open function to simulate headless failure.
300+
orig := openBrowserFunc
301+
openBrowserFunc = func(url string) error {
302+
return fmt.Errorf("no display available")
303+
}
304+
t.Cleanup(func() { openBrowserFunc = orig })
305+
306+
// Provide stdin replacement so loginManual can read the pasted key.
307+
stdinInput := "smsk_live_headless_test\n"
308+
origStdinReader := stdinReader
309+
stdinReader = strings.NewReader(stdinInput)
310+
t.Cleanup(func() { stdinReader = origStdinReader })
311+
312+
// Capture output to verify the auth URL was printed.
313+
var outBuf bytes.Buffer
314+
origOut := loginOut
315+
loginOut = &outBuf
316+
t.Cleanup(func() { loginOut = origOut })
317+
318+
ctx := context.Background()
319+
if err := Login(ctx); err != nil {
320+
t.Fatalf("Login returned unexpected error: %v", err)
321+
}
322+
323+
output := outBuf.String()
324+
325+
// The auth URL (with port and state) must appear in the output so the user
326+
// can visit it in a separate browser.
327+
if !strings.Contains(output, dashboardBase+"/cli-auth") {
328+
t.Errorf("expected auth URL containing %q in output, got:\n%s", dashboardBase+"/cli-auth", output)
329+
}
330+
331+
// A prompt telling the user to paste their API key must appear.
332+
if !strings.Contains(output, "Paste your API key") {
333+
t.Errorf("expected 'Paste your API key' prompt in output, got:\n%s", output)
334+
}
335+
336+
// The API key must have been saved.
337+
cfg, err := config.Load()
338+
if err != nil {
339+
t.Fatal(err)
340+
}
341+
if cfg.APIKey != "smsk_live_headless_test" {
342+
t.Errorf("expected API key %q saved, got %q", "smsk_live_headless_test", cfg.APIKey)
343+
}
344+
}

0 commit comments

Comments
 (0)