Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions cmd/nlm/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import (

var extractNotebookLMPageState = auth.ExtractNotebookLMPageState

var getBrowserAuthData = func(debug bool, opts ...auth.Option) (*auth.AuthData, error) {
return auth.New(debug).GetAuthData(opts...)
}

// maskProfileName masks sensitive profile names in debug output
func maskProfileName(profile string) string {
if profile == "" {
Expand Down Expand Up @@ -150,8 +154,6 @@ func handleDecodedAuth(args authArgs) (string, string, error) {
// Use the debug flag from options if set, otherwise use the global debug flag
useDebug := opts.Debug || globals.debug

a := auth.New(useDebug)

// Prepare options for auth call
// Custom options
authOpts := []auth.Option{auth.WithScanBeforeAuth(), auth.WithTargetURL(opts.TargetURL)}
Expand Down Expand Up @@ -184,7 +186,11 @@ func handleDecodedAuth(args authArgs) (string, string, error) {
}

// Get auth data (use GetAuthData to capture session ID and BL param)
authData, err := a.GetAuthData(authOpts...)
authData, err := getBrowserAuthData(useDebug, authOpts...)
if err != nil && !useDebug && opts.RemoteCDPURL == "" && shouldRetryAuthVisibly(err) {
fmt.Fprintln(os.Stderr, "nlm: headless authentication failed; retrying with a visible browser...")
authData, err = getBrowserAuthData(false, visibleAuthRetryOptions(authOpts, opts.KeepOpenSeconds)...)
}
if err != nil {
return "", "", fmt.Errorf("browser auth failed: %w", err)
}
Expand All @@ -199,6 +205,33 @@ func handleDecodedAuth(args authArgs) (string, string, error) {
return authToken, cookies, nil
}

func visibleAuthRetryOptions(base []auth.Option, keepOpenSeconds int) []auth.Option {
opts := append([]auth.Option{}, base...)
opts = append(opts, auth.WithVisibleBrowser())
if keepOpenSeconds == 0 {
opts = append(opts, auth.WithKeepOpenSeconds(30))
}
return opts
}

func shouldRetryAuthVisibly(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, prelaunchFailure := range []string{
"scan profiles",
"no valid browser profiles",
"create temp dir",
"copy profile",
} {
if strings.Contains(message, prelaunchFailure) {
return false
}
}
return true
}

// printAuthEnv writes POSIX `export KEY=value` lines for the current session
// to w. Only NLM_AUTH_TOKEN and NLM_COOKIES are written; other values
// (profile name, session id, build label, signaler auth) are derived per
Expand Down
51 changes: 51 additions & 0 deletions cmd/nlm/auth_visible_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"errors"
"testing"

"github.com/tmc/nlm/internal/auth"
)

func TestShouldRetryAuthVisibly(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "headless authentication rejected", err: errors.New("redirected to authentication page - not logged in"), want: true},
{name: "chrome launch failed", err: errors.New("failed to load page: chrome failed to start"), want: true},
{name: "profile scan failed", err: errors.New("scan profiles: permission denied"), want: false},
{name: "profile copy failed", err: errors.New("copy profile: permission denied"), want: false},
{name: "nil", err: nil, want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldRetryAuthVisibly(tt.err); got != tt.want {
t.Fatalf("shouldRetryAuthVisibly(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}

func TestVisibleBrowserOption(t *testing.T) {
opts := &auth.Options{}
for _, apply := range visibleAuthRetryOptions(nil, 0) {
apply(opts)
}
if !opts.VisibleBrowser {
t.Fatal("visible auth retry did not enable a headed browser")
}
if opts.KeepOpenSeconds != 30 {
t.Fatalf("visible auth retry keep-open = %d, want 30", opts.KeepOpenSeconds)
}

opts = &auth.Options{}
for _, apply := range visibleAuthRetryOptions([]auth.Option{auth.WithKeepOpenSeconds(12)}, 12) {
apply(opts)
}
if opts.KeepOpenSeconds != 12 {
t.Fatalf("visible auth retry replaced explicit keep-open with %d", opts.KeepOpenSeconds)
}
}
8 changes: 6 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const appOrigin = "https://notebook.google.com"

type BrowserAuth struct {
debug bool
visible bool
tempDir string
chromeCmd *exec.Cmd
cancel context.CancelFunc
Expand Down Expand Up @@ -94,6 +95,7 @@ type Options struct {
KeepOpenSeconds int // Keep browser open for N seconds after auth
RemoteCDPURL string // Remote CDP WebSocket URL (e.g. "ws://localhost:9222")
AuthUser string // Google account index for multi-account profiles (e.g. "1")
VisibleBrowser bool // Force a headed browser without enabling debug logging
}

type Option func(*Options)
Expand All @@ -110,6 +112,7 @@ func WithCheckNotebooks() Option { return func(o *Options) { o.Check
func WithKeepOpenSeconds(seconds int) Option { return func(o *Options) { o.KeepOpenSeconds = seconds } }
func WithRemoteCDPURL(url string) Option { return func(o *Options) { o.RemoteCDPURL = url } }
func WithAuthUser(authUser string) Option { return func(o *Options) { o.AuthUser = authUser } }
func WithVisibleBrowser() Option { return func(o *Options) { o.VisibleBrowser = true } }

func defaultBrowserAuthOptions() *Options {
return &Options{
Expand Down Expand Up @@ -222,7 +225,7 @@ func (ba *BrowserAuth) tryMultipleProfiles(targetURL string) (token, cookies str
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.UserDataDir(userDataDir),
chromedp.Flag("headless", !ba.debug),
chromedp.Flag("headless", !(ba.debug || ba.visible)),
chromedp.Flag("window-size", "1280,800"),
chromedp.Flag("new-window", true),
chromedp.Flag("no-first-run", true),
Expand Down Expand Up @@ -510,6 +513,7 @@ func (ba *BrowserAuth) GetAuth(opts ...Option) (token, cookies string, err error

// Store keep-open setting in the struct
ba.keepOpenSeconds = o.KeepOpenSeconds
ba.visible = o.VisibleBrowser

// If a remote CDP URL is provided, connect to it directly
if o.RemoteCDPURL != "" {
Expand Down Expand Up @@ -733,7 +737,7 @@ func (ba *BrowserAuth) GetAuth(opts ...Option) (token, cookies string, err error
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.UserDataDir(ba.tempDir),
chromedp.Flag("headless", !ba.debug),
chromedp.Flag("headless", !(ba.debug || ba.visible)),
chromedp.Flag("window-size", "1280,800"),
chromedp.Flag("new-window", true),
chromedp.Flag("no-first-run", true),
Expand Down