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-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 } : {}), }), }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx index e5d9798dd8..5d98c22c9d 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx @@ -392,32 +392,38 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto // connects here, `justConnected` advances to the "add your first automation" // state. Connections are org-level and reused, so connecting an already-connected // vendor simply reuses the saved session. - // A pending draft means the task is mid-setup — don't drop back to first-run. - if ( - !isManualTask && - automations.automations.length === 0 && - !justConnected && - drafts.length === 0 - ) { - return ( - setConnectOpen(true)} + // No saved automations yet. + if (!isManualTask && automations.automations.length === 0) { + // An automation can only be built on an existing connection — the composer + // opens on one. With no connection (and none just made — the profile list + // refetches a beat after connecting), "Create" would silently no-op because + // there's nothing to open the composer on, so prompt to connect first. + // Drafts are still surfaced above so they can be resumed either way. + const hasConnection = profiles.length > 0; + const draftsStrip = drafts.length > 0 && ( + ); - } - // No saved automations yet, but a connection and/or a draft exists — prompt to - // create the first one, with any drafts pinned above to resume. - if (!isManualTask && automations.automations.length === 0) { + if (!hasConnection && !justConnected) { + return ( + <> + {draftsStrip} + setConnectOpen(true)} + /> + + ); + } + return ( <> - + {draftsStrip} setComposer({ open: true, mode: 'create' })} />