From 08ba2b57b1bc03b9d84bbf7d6e3c2ad4f3392f43 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 11:40:54 -0400 Subject: [PATCH 1/2] fix(api): follow the live view to the 2FA page after sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect flow only re-pointed the live view before the sign-in (after finding the form). Once the AI submitted and the vendor showed a 2FA prompt, the iframe stayed on the pre-submit login tab — so a user asked to enter their 2FA code was looking at an empty login form and couldn't. Re-emit the live view right after the sign-in attempt, at the same tab the classifier judged, so the take-over happens on the page the user actually sees. +test. --- .../browser-credential-signin.service.spec.ts | 28 +++++++++++++++++++ .../browser-credential-signin.service.ts | 12 ++++++++ 2 files changed, 40 insertions(+) diff --git a/apps/api/src/browserbase/browser-credential-signin.service.spec.ts b/apps/api/src/browserbase/browser-credential-signin.service.spec.ts index 0549264532..1aa5a0a20f 100644 --- a/apps/api/src/browserbase/browser-credential-signin.service.spec.ts +++ b/apps/api/src/browserbase/browser-credential-signin.service.spec.ts @@ -35,6 +35,9 @@ function makeSessions(extract: jest.Mock, act: jest.Mock) { ensureActivePage: jest.fn().mockResolvedValue(page), safeCloseStagehand: jest.fn().mockResolvedValue(undefined), closeSession: jest.fn().mockResolvedValue(undefined), + getActivePageLiveViewUrl: jest + .fn() + .mockResolvedValue('https://live-view/current'), }; } @@ -172,6 +175,31 @@ describe('BrowserCredentialSigninService', () => { expect(profiles.markNeedsReauth).toHaveBeenCalledTimes(1); }); + it('re-points the live view after the sign-in attempt so a 2FA take-over sees the right tab', async () => { + const extract = jest + .fn() + .mockResolvedValueOnce({ state: 'unknown' }) + .mockResolvedValue({ state: 'needs_2fa' }); + const sessions = makeSessions(extract, jest.fn().mockResolvedValue(undefined)); + const profiles = makeProfiles(profile); + withCredentials({ username: 'user@x.com', password: 'secret' }); // no code → manual 2FA + const onLiveView = jest.fn(); + const service = new BrowserCredentialSigninService( + sessions as unknown as BrowserbaseSessionService, + profiles as unknown as BrowserAuthProfileService, + ); + + const promise = service.signInWithStoredCredentials({ ...input, onLiveView }); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.failure).toBe('needs_2fa'); + // Emitted after navigateToSignIn AND again after the sign-in attempt — so the + // iframe follows to the 2FA page instead of staying on the pre-submit login + // tab (the reported "your turn, but I can't see the code field" bug). + expect(onLiveView.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + it('throws when the profile does not exist', async () => { const sessions = makeSessions(jest.fn(), jest.fn()); const profiles = makeProfiles(null); diff --git a/apps/api/src/browserbase/browser-credential-signin.service.ts b/apps/api/src/browserbase/browser-credential-signin.service.ts index b6c3f9a12b..e27f4b92bc 100644 --- a/apps/api/src/browserbase/browser-credential-signin.service.ts +++ b/apps/api/src/browserbase/browser-credential-signin.service.ts @@ -218,6 +218,18 @@ export class BrowserCredentialSigninService { // back to the one detected + stored at connect time. usernameLabel: input.usernameLabel ?? profile.identifierLabel ?? undefined, }); + // The sign-in attempt navigated — often to a 2FA / verification page, and + // sometimes in a new tab. Re-point the live view at the tab the classifier + // just judged (same newest-tab selection it used), so a take-over — e.g. + // typing the 2FA code — happens on the page the user actually sees. Without + // this the iframe stays on the pre-submit login tab and the code field is + // invisible, which is exactly the "your turn, but I can't see the field" + // state users hit. + await this.streamActiveLiveView( + activeStagehand, + input.sessionId, + input.onLiveView, + ); step('Checking whether that worked'); if (outcome === 'logged_in') { From bb7c2457926ed8a5035df49bfcd03aa609c213fd Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 11:49:28 -0400 Subject: [PATCH 2/2] fix(api): keep the connect session alive through a 2FA take-over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect session was created without a timeout, so it used the project's short default and Browserbase auto-ended it (~2 min) — killing the live view while a user was still fetching their authenticator / entering a 2FA code (the 'Debugging connection was closed' notice). Give human-facing sessions an explicit 15-minute timeout; headless capture runs keep the short default. +tests. --- .../browser-auth-profile.service.ts | 7 ++++- .../browserbase-session.service.spec.ts | 28 +++++++++++++++++++ .../browserbase-session.service.ts | 15 ++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/api/src/browserbase/browser-auth-profile.service.ts b/apps/api/src/browserbase/browser-auth-profile.service.ts index 5921cb44fa..49c7450475 100644 --- a/apps/api/src/browserbase/browser-auth-profile.service.ts +++ b/apps/api/src/browserbase/browser-auth-profile.service.ts @@ -13,6 +13,7 @@ import { } from './browserbase-url'; import { BrowserbaseSessionService, + INTERACTIVE_SESSION_TIMEOUT_SECONDS, INTERACTIVE_VIEWPORT, } from './browserbase-session.service'; import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; @@ -197,10 +198,14 @@ export class BrowserAuthProfileService { } const readyProfile = await this.profileContexts.ready(profile); // Human-facing session — use the smaller viewport so the sign-in page reads - // larger in the live view. + // larger in the live view, and a generous timeout so the session (and its + // live view) survives a 2FA take-over instead of dying on the project's + // short default. return this.sessions.createSessionWithContext( readyProfile.contextId, INTERACTIVE_VIEWPORT, + true, + INTERACTIVE_SESSION_TIMEOUT_SECONDS, ); } diff --git a/apps/api/src/browserbase/browserbase-session.service.spec.ts b/apps/api/src/browserbase/browserbase-session.service.spec.ts index edf950016e..2e1b182199 100644 --- a/apps/api/src/browserbase/browserbase-session.service.spec.ts +++ b/apps/api/src/browserbase/browserbase-session.service.spec.ts @@ -194,6 +194,34 @@ describe('BrowserbaseSessionService', () => { expect(debugSession).toHaveBeenCalledTimes(2); }); + it('forwards a session timeout only when one is given (interactive take-over headroom)', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const createSession = jest.fn().mockResolvedValue({ id: 'session_1' }); + const debugSession = jest.fn().mockResolvedValue({ + debuggerFullscreenUrl: 'https://live.browserbase.test/session_1', + }); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ createSession, debugSession })); + + // Interactive flow: the generous timeout is forwarded to Browserbase. + const interactive = service.createSessionWithContext('ctx_1', undefined, true, 900); + await jest.advanceTimersByTimeAsync(250); + await interactive; + expect(createSession).toHaveBeenLastCalledWith( + expect.objectContaining({ timeout: 900 }), + ); + + // Headless default: no timeout key — the session uses the project default. + const headless = service.createSessionWithContext('ctx_1'); + await jest.advanceTimersByTimeAsync(250); + await headless; + expect(createSession).toHaveBeenLastCalledWith( + expect.not.objectContaining({ timeout: expect.anything() }), + ); + }); + it('resolves the session connect URL via the identity-encoded client', async () => { jest.useFakeTimers(); const service = new BrowserbaseSessionService(); diff --git a/apps/api/src/browserbase/browserbase-session.service.ts b/apps/api/src/browserbase/browserbase-session.service.ts index 0bf6c94222..4623937795 100644 --- a/apps/api/src/browserbase/browserbase-session.service.ts +++ b/apps/api/src/browserbase/browserbase-session.service.ts @@ -26,6 +26,15 @@ export const CAPTURE_VIEWPORT: BrowserViewport = { width: 1920, height: 1080 }; // reconnect): the page renders larger in the embedded live view, so forms are // easier to read and fill. Capture runs stay on CAPTURE_VIEWPORT. export const INTERACTIVE_VIEWPORT: BrowserViewport = { width: 1280, height: 800 }; + +// How long a human-facing session may live before Browserbase auto-ends it. +// Without an explicit timeout the session uses the project default (short), so +// the live view dies mid-take-over — e.g. while a user fetches their +// authenticator and types a 2FA code. 15 minutes gives ample take-over headroom; +// the connect flow still closes the session promptly when the user finishes or +// cancels, so this only bounds an abandoned session. Headless capture runs keep +// the short default (no human is waiting). +export const INTERACTIVE_SESSION_TIMEOUT_SECONDS = 15 * 60; // Model behind extract()/act() (reading pages, verdicts, form fills). Separate // from the navigation (CUA) model and configurable via env; default unchanged. const STAGEHAND_MODEL = @@ -109,6 +118,11 @@ export class BrowserbaseSessionService { // need their cookies saved). Read-only flows (login analysis) pass false so // they never retain cookies in a throwaway context. persist: boolean = true, + // Seconds before Browserbase auto-ends the session. Omit for the project + // default (short — fine for headless runs). Interactive flows that hand the + // browser to a person pass a generous value so the live view survives a + // take-over (e.g. entering a 2FA code). + timeoutSeconds?: number, ): Promise<{ sessionId: string; liveViewUrl: string }> { const bb = this.getBrowserbase(); @@ -133,6 +147,7 @@ export class BrowserbaseSessionService { viewport: { width: viewport.width, height: viewport.height }, }, keepAlive: true, + ...(timeoutSeconds ? { timeout: timeoutSeconds } : {}), }), });