From 295608698265959107bfbbf0cddf9f6991392ba7 Mon Sep 17 00:00:00 2001 From: Michael Gardiner <34581105+gardinermichael@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:04:10 -0700 Subject: [PATCH] Fix browser auth fallback --- cmd/nlm/auth.go | 39 +++++++++++++++++++++-- cmd/nlm/auth_visible_retry_test.go | 51 ++++++++++++++++++++++++++++++ internal/auth/auth.go | 8 +++-- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 cmd/nlm/auth_visible_retry_test.go diff --git a/cmd/nlm/auth.go b/cmd/nlm/auth.go index 8e540d7f..dbc387cb 100644 --- a/cmd/nlm/auth.go +++ b/cmd/nlm/auth.go @@ -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 == "" { @@ -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)} @@ -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) } @@ -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 diff --git a/cmd/nlm/auth_visible_retry_test.go b/cmd/nlm/auth_visible_retry_test.go new file mode 100644 index 00000000..fd5c2ce1 --- /dev/null +++ b/cmd/nlm/auth_visible_retry_test.go @@ -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) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b79e90b5..0f128282 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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 @@ -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) @@ -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{ @@ -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), @@ -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 != "" { @@ -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),