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/browser-credential-login.spec.ts b/apps/api/src/browserbase/browser-credential-login.spec.ts index 18bc320fba..480e034e82 100644 --- a/apps/api/src/browserbase/browser-credential-login.spec.ts +++ b/apps/api/src/browserbase/browser-credential-login.spec.ts @@ -67,6 +67,28 @@ describe('performCredentialLogin', () => { expect(instruction).not.toContain('424242'); } }); + + it('switches a passkey / security-key prompt to the authenticator-code method', async () => { + const stagehand = makeStagehand(); + + const promise = performCredentialLogin({ + stagehand: stagehand as unknown as Stagehand, + credentials: { username: 'alice', password: 'pw' }, // no stored code → take-over + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + await promise; + + // A best-effort step switches to the code method when a vendor defaults 2FA + // to a passkey, so a 6-digit field is actually shown (for us or the user). + const calls = stagehand.act.mock.calls as [string, unknown?][]; + const switchCall = calls.find( + ([instruction]) => + instruction.includes('passkey') && + instruction.includes('authenticator app'), + ); + expect(switchCall).toBeDefined(); + }); }); describe('reloginWithStoredCredentials', () => { diff --git a/apps/api/src/browserbase/browser-credential-login.ts b/apps/api/src/browserbase/browser-credential-login.ts index bcae5ea779..c1aa41b00c 100644 --- a/apps/api/src/browserbase/browser-credential-login.ts +++ b/apps/api/src/browserbase/browser-credential-login.ts @@ -111,6 +111,16 @@ export async function performCredentialLogin({ await delay(2000); } + // Some vendors (e.g. GitHub) default the two-factor step to a passkey / + // security-key prompt, which we can't use. If we've landed on one, switch to + // the authenticator-app option so a 6-digit code field is actually shown — for + // us to fill a stored code, or for the user to type one during take-over. + // Best-effort; a no-op when the page isn't a passkey prompt. + await stagehand.act( + "If this page is asking to authenticate with a passkey or security key (for example a 'Use passkey' button) instead of a code, switch to the authenticator app: click 'More options', then 'Use authenticator app', 'Enter a two-factor code', 'Use a security code', or a similar option so a six-digit code field appears. If a code field is already visible, or this is not a two-factor page, do nothing.", + ); + await delay(1500); + if (credentials.totpCode) { log('Entering one-time passcode.'); await stagehand.act( 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') { 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 } : {}), }), });