From 99fe05f7d4f8e15c10c880c9a9e97684966d7a7d Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Thu, 30 Apr 2026 10:31:09 -0400 Subject: [PATCH 1/2] test: failing test for headless browser auth fallback (#155) --- internal/auth/handler_test.go | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/internal/auth/handler_test.go b/internal/auth/handler_test.go index 533e150..eac9123 100644 --- a/internal/auth/handler_test.go +++ b/internal/auth/handler_test.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "fmt" "net" @@ -8,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -284,3 +286,59 @@ func TestLogout_SaveError(t *testing.T) { t.Error("expected error when cfg.Save fails during logout") } } + +// TestLoginFallback_HeadlessBrowser verifies that when the browser cannot be +// opened (headless/SSH/container environments), Login prints the auth URL to +// stdout and falls back to prompting the user to paste an API key manually. +func TestLoginFallback_HeadlessBrowser(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("SUPERMODEL_API_KEY", "") + + // Override the injectable browser-open function to simulate headless failure. + orig := openBrowserFunc + openBrowserFunc = func(url string) error { + return fmt.Errorf("no display available") + } + t.Cleanup(func() { openBrowserFunc = orig }) + + // Provide stdin replacement so loginManual can read the pasted key. + stdinInput := "smsk_live_headless_test\n" + origStdinReader := stdinReader + stdinReader = strings.NewReader(stdinInput) + t.Cleanup(func() { stdinReader = origStdinReader }) + + // Capture output to verify the auth URL was printed. + var outBuf bytes.Buffer + origOut := loginOut + loginOut = &outBuf + t.Cleanup(func() { loginOut = origOut }) + + ctx := context.Background() + if err := Login(ctx); err != nil { + t.Fatalf("Login returned unexpected error: %v", err) + } + + output := outBuf.String() + + // The auth URL (with port and state) must appear in the output so the user + // can visit it in a separate browser. + if !strings.Contains(output, dashboardBase+"/cli-auth") { + t.Errorf("expected auth URL containing %q in output, got:\n%s", dashboardBase+"/cli-auth", output) + } + + // A prompt telling the user to paste their API key must appear. + if !strings.Contains(output, "Paste your API key") { + t.Errorf("expected 'Paste your API key' prompt in output, got:\n%s", output) + } + + // The API key must have been saved. + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + if cfg.APIKey != "smsk_live_headless_test" { + t.Errorf("expected API key %q saved, got %q", "smsk_live_headless_test", cfg.APIKey) + } +} From 17500d302112cf9ee876e6b47af48dea81b0acb3 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Thu, 30 Apr 2026 10:35:44 -0400 Subject: [PATCH 2/2] 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 --- internal/auth/handler.go | 62 ++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 3f187c2..0dbc286 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" "net" "net/http" "os" @@ -23,6 +24,18 @@ import ( const dashboardBase = "https://dashboard.supermodeltools.com" +// loginOut is the writer used for all Login output. Override in tests to +// capture output without touching os.Stdout. +var loginOut io.Writer = os.Stdout + +// stdinReader is the reader used by readSecret in non-TTY mode. Override in +// tests to supply canned input without touching os.Stdin. +var stdinReader io.Reader = os.Stdin + +// openBrowserFunc is the injectable browser-open function. Override in tests +// to simulate headless environments where a browser cannot be launched. +var openBrowserFunc = openBrowserDefault + // Login runs the browser-based login flow. Opens the dashboard to create an // API key, receives it via localhost callback, validates, and saves it. // Falls back to manual paste if the browser flow fails. @@ -35,8 +48,8 @@ func Login(ctx context.Context) error { // Start localhost server on a random port. listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - fmt.Fprintln(os.Stderr, "Could not start local server — falling back to manual login.") - return loginManual(cfg) + fmt.Fprintln(loginOut, "Could not start local server — falling back to manual login.") + return loginManual(cfg, "") } port := listener.Addr().(*net.TCPAddr).Port state := randomState() @@ -71,20 +84,20 @@ func Login(ctx context.Context) error { // Build the dashboard URL and open the browser. authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", dashboardBase, port, state) - fmt.Println("Opening browser to log in...") - fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", authURL) + fmt.Fprintln(loginOut, "Opening browser to log in...") + fmt.Fprintf(loginOut, "If the browser doesn't open, visit:\n %s\n\n", authURL) - if err := openBrowser(authURL); err != nil { - fmt.Fprintln(os.Stderr, "Could not open browser — falling back to manual login.") + if err := openBrowserFunc(authURL); err != nil { + fmt.Fprintln(loginOut, "Could not open browser — falling back to manual login.") srv.Close() - return loginManual(cfg) + return loginManual(cfg, authURL) } // Wait for callback or timeout. - fmt.Print("Waiting for authentication...") + fmt.Fprint(loginOut, "Waiting for authentication...") select { case key := <-keyCh: - fmt.Println() + fmt.Fprintln(loginOut) cfg.APIKey = strings.TrimSpace(key) if err := cfg.Save(); err != nil { return err @@ -92,15 +105,15 @@ func Login(ctx context.Context) error { ui.Success("Authenticated — key saved to %s", config.Path()) return nil case err := <-errCh: - fmt.Println() + fmt.Fprintln(loginOut) return fmt.Errorf("local server error: %w", err) case <-time.After(5 * time.Minute): - fmt.Println() - fmt.Fprintln(os.Stderr, "Timed out waiting for browser login — falling back to manual login.") + fmt.Fprintln(loginOut) + fmt.Fprintln(loginOut, "Timed out waiting for browser login — falling back to manual login.") srv.Close() - return loginManual(cfg) + return loginManual(cfg, authURL) case <-ctx.Done(): - fmt.Println() + fmt.Fprintln(loginOut) return ctx.Err() } } @@ -141,10 +154,16 @@ func Logout(_ context.Context) error { return nil } -// loginManual is the fallback paste-based login. -func loginManual(cfg *config.Config) error { - fmt.Println("Get your API key at https://dashboard.supermodeltools.com/api-keys") - fmt.Print("Paste your API key: ") +// loginManual is the fallback paste-based login. When authURL is non-empty +// (i.e. the browser-open step failed), it is printed so the user can visit it +// from another machine or browser. +func loginManual(cfg *config.Config, authURL string) error { + if authURL != "" { + fmt.Fprintf(loginOut, "Visit the following URL to get your API key:\n %s\n\n", authURL) + } else { + fmt.Fprintf(loginOut, "Get your API key at %s/api-keys\n", dashboardBase) + } + fmt.Fprint(loginOut, "Paste your API key: ") key, err := readSecret() if err != nil { @@ -163,7 +182,7 @@ func loginManual(cfg *config.Config) error { return nil } -func openBrowser(url string) error { +func openBrowserDefault(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() @@ -183,17 +202,18 @@ func randomState() string { } // readSecret reads a line from stdin, suppressing echo when a TTY is attached. +// In non-TTY mode it reads from stdinReader (injectable for tests). func readSecret() (string, error) { fd := int(syscall.Stdin) //nolint:unconvert // syscall.Stdin is uintptr on Windows if term.IsTerminal(fd) { b, err := term.ReadPassword(fd) - fmt.Println() + fmt.Fprintln(loginOut) if err != nil { return "", err } return string(b), nil } - scanner := bufio.NewScanner(os.Stdin) + scanner := bufio.NewScanner(stdinReader) if scanner.Scan() { return scanner.Text(), nil }