Skip to content
Merged
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
7 changes: 6 additions & 1 deletion apps/api/src/browserbase/browser-auth-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
);
}

Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/browserbase/browser-credential-signin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};
}

Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/browserbase/browser-credential-signin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/browserbase/browserbase-session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/browserbase/browserbase-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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();

Expand All @@ -133,6 +147,7 @@ export class BrowserbaseSessionService {
viewport: { width: viewport.width, height: viewport.height },
},
keepAlive: true,
...(timeoutSeconds ? { timeout: timeoutSeconds } : {}),
}),
});

Expand Down
Loading