Skip to content

Commit 711ebf3

Browse files
authored
Merge pull request #3517 from trycompai/main
[comp] Production Deploy
2 parents f58ea13 + 631d87a commit 711ebf3

6 files changed

Lines changed: 116 additions & 22 deletions

File tree

‎apps/api/src/browserbase/browser-auth-profile.service.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from './browserbase-url';
1414
import {
1515
BrowserbaseSessionService,
16+
INTERACTIVE_SESSION_TIMEOUT_SECONDS,
1617
INTERACTIVE_VIEWPORT,
1718
} from './browserbase-session.service';
1819
import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service';
@@ -197,10 +198,14 @@ export class BrowserAuthProfileService {
197198
}
198199
const readyProfile = await this.profileContexts.ready(profile);
199200
// Human-facing session — use the smaller viewport so the sign-in page reads
200-
// larger in the live view.
201+
// larger in the live view, and a generous timeout so the session (and its
202+
// live view) survives a 2FA take-over instead of dying on the project's
203+
// short default.
201204
return this.sessions.createSessionWithContext(
202205
readyProfile.contextId,
203206
INTERACTIVE_VIEWPORT,
207+
true,
208+
INTERACTIVE_SESSION_TIMEOUT_SECONDS,
204209
);
205210
}
206211

‎apps/api/src/browserbase/browser-credential-signin.service.spec.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ function makeSessions(extract: jest.Mock, act: jest.Mock) {
3535
ensureActivePage: jest.fn().mockResolvedValue(page),
3636
safeCloseStagehand: jest.fn().mockResolvedValue(undefined),
3737
closeSession: jest.fn().mockResolvedValue(undefined),
38+
getActivePageLiveViewUrl: jest
39+
.fn()
40+
.mockResolvedValue('https://live-view/current'),
3841
};
3942
}
4043

@@ -172,6 +175,31 @@ describe('BrowserCredentialSigninService', () => {
172175
expect(profiles.markNeedsReauth).toHaveBeenCalledTimes(1);
173176
});
174177

178+
it('re-points the live view after the sign-in attempt so a 2FA take-over sees the right tab', async () => {
179+
const extract = jest
180+
.fn()
181+
.mockResolvedValueOnce({ state: 'unknown' })
182+
.mockResolvedValue({ state: 'needs_2fa' });
183+
const sessions = makeSessions(extract, jest.fn().mockResolvedValue(undefined));
184+
const profiles = makeProfiles(profile);
185+
withCredentials({ username: 'user@x.com', password: 'secret' }); // no code → manual 2FA
186+
const onLiveView = jest.fn();
187+
const service = new BrowserCredentialSigninService(
188+
sessions as unknown as BrowserbaseSessionService,
189+
profiles as unknown as BrowserAuthProfileService,
190+
);
191+
192+
const promise = service.signInWithStoredCredentials({ ...input, onLiveView });
193+
await jest.runAllTimersAsync();
194+
const result = await promise;
195+
196+
expect(result.failure).toBe('needs_2fa');
197+
// Emitted after navigateToSignIn AND again after the sign-in attempt — so the
198+
// iframe follows to the 2FA page instead of staying on the pre-submit login
199+
// tab (the reported "your turn, but I can't see the code field" bug).
200+
expect(onLiveView.mock.calls.length).toBeGreaterThanOrEqual(2);
201+
});
202+
175203
it('throws when the profile does not exist', async () => {
176204
const sessions = makeSessions(jest.fn(), jest.fn());
177205
const profiles = makeProfiles(null);

‎apps/api/src/browserbase/browser-credential-signin.service.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ export class BrowserCredentialSigninService {
218218
// back to the one detected + stored at connect time.
219219
usernameLabel: input.usernameLabel ?? profile.identifierLabel ?? undefined,
220220
});
221+
// The sign-in attempt navigated — often to a 2FA / verification page, and
222+
// sometimes in a new tab. Re-point the live view at the tab the classifier
223+
// just judged (same newest-tab selection it used), so a take-over — e.g.
224+
// typing the 2FA code — happens on the page the user actually sees. Without
225+
// this the iframe stays on the pre-submit login tab and the code field is
226+
// invisible, which is exactly the "your turn, but I can't see the field"
227+
// state users hit.
228+
await this.streamActiveLiveView(
229+
activeStagehand,
230+
input.sessionId,
231+
input.onLiveView,
232+
);
221233
step('Checking whether that worked');
222234

223235
if (outcome === 'logged_in') {

‎apps/api/src/browserbase/browserbase-session.service.spec.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,34 @@ describe('BrowserbaseSessionService', () => {
194194
expect(debugSession).toHaveBeenCalledTimes(2);
195195
});
196196

197+
it('forwards a session timeout only when one is given (interactive take-over headroom)', async () => {
198+
jest.useFakeTimers();
199+
const service = new BrowserbaseSessionService();
200+
const createSession = jest.fn().mockResolvedValue({ id: 'session_1' });
201+
const debugSession = jest.fn().mockResolvedValue({
202+
debuggerFullscreenUrl: 'https://live.browserbase.test/session_1',
203+
});
204+
jest
205+
.spyOn(service, 'getBrowserbase')
206+
.mockReturnValue(mockBrowserbaseClient({ createSession, debugSession }));
207+
208+
// Interactive flow: the generous timeout is forwarded to Browserbase.
209+
const interactive = service.createSessionWithContext('ctx_1', undefined, true, 900);
210+
await jest.advanceTimersByTimeAsync(250);
211+
await interactive;
212+
expect(createSession).toHaveBeenLastCalledWith(
213+
expect.objectContaining({ timeout: 900 }),
214+
);
215+
216+
// Headless default: no timeout key — the session uses the project default.
217+
const headless = service.createSessionWithContext('ctx_1');
218+
await jest.advanceTimersByTimeAsync(250);
219+
await headless;
220+
expect(createSession).toHaveBeenLastCalledWith(
221+
expect.not.objectContaining({ timeout: expect.anything() }),
222+
);
223+
});
224+
197225
it('resolves the session connect URL via the identity-encoded client', async () => {
198226
jest.useFakeTimers();
199227
const service = new BrowserbaseSessionService();

‎apps/api/src/browserbase/browserbase-session.service.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export const CAPTURE_VIEWPORT: BrowserViewport = { width: 1920, height: 1080 };
2626
// reconnect): the page renders larger in the embedded live view, so forms are
2727
// easier to read and fill. Capture runs stay on CAPTURE_VIEWPORT.
2828
export const INTERACTIVE_VIEWPORT: BrowserViewport = { width: 1280, height: 800 };
29+
30+
// How long a human-facing session may live before Browserbase auto-ends it.
31+
// Without an explicit timeout the session uses the project default (short), so
32+
// the live view dies mid-take-over — e.g. while a user fetches their
33+
// authenticator and types a 2FA code. 15 minutes gives ample take-over headroom;
34+
// the connect flow still closes the session promptly when the user finishes or
35+
// cancels, so this only bounds an abandoned session. Headless capture runs keep
36+
// the short default (no human is waiting).
37+
export const INTERACTIVE_SESSION_TIMEOUT_SECONDS = 15 * 60;
2938
// Model behind extract()/act() (reading pages, verdicts, form fills). Separate
3039
// from the navigation (CUA) model and configurable via env; default unchanged.
3140
const STAGEHAND_MODEL =
@@ -109,6 +118,11 @@ export class BrowserbaseSessionService {
109118
// need their cookies saved). Read-only flows (login analysis) pass false so
110119
// they never retain cookies in a throwaway context.
111120
persist: boolean = true,
121+
// Seconds before Browserbase auto-ends the session. Omit for the project
122+
// default (short — fine for headless runs). Interactive flows that hand the
123+
// browser to a person pass a generous value so the live view survives a
124+
// take-over (e.g. entering a 2FA code).
125+
timeoutSeconds?: number,
112126
): Promise<{ sessionId: string; liveViewUrl: string }> {
113127
const bb = this.getBrowserbase();
114128

@@ -133,6 +147,7 @@ export class BrowserbaseSessionService {
133147
viewport: { width: viewport.width, height: viewport.height },
134148
},
135149
keepAlive: true,
150+
...(timeoutSeconds ? { timeout: timeoutSeconds } : {}),
136151
}),
137152
});
138153

‎apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx‎

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -392,32 +392,38 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto
392392
// connects here, `justConnected` advances to the "add your first automation"
393393
// state. Connections are org-level and reused, so connecting an already-connected
394394
// vendor simply reuses the saved session.
395-
// A pending draft means the task is mid-setup — don't drop back to first-run.
396-
if (
397-
!isManualTask &&
398-
automations.automations.length === 0 &&
399-
!justConnected &&
400-
drafts.length === 0
401-
) {
402-
return (
403-
<BrowserEvidenceEmptyState
404-
isStartingAuth={context.isStartingAuth}
405-
onConnect={() => setConnectOpen(true)}
395+
// No saved automations yet.
396+
if (!isManualTask && automations.automations.length === 0) {
397+
// An automation can only be built on an existing connection — the composer
398+
// opens on one. With no connection (and none just made — the profile list
399+
// refetches a beat after connecting), "Create" would silently no-op because
400+
// there's nothing to open the composer on, so prompt to connect first.
401+
// Drafts are still surfaced above so they can be resumed either way.
402+
const hasConnection = profiles.length > 0;
403+
const draftsStrip = drafts.length > 0 && (
404+
<DraftsStrip
405+
drafts={drafts}
406+
profiles={profiles}
407+
onContinue={handleContinueDraft}
408+
onDelete={handleDeleteDraft}
406409
/>
407410
);
408-
}
409411

410-
// No saved automations yet, but a connection and/or a draft exists — prompt to
411-
// create the first one, with any drafts pinned above to resume.
412-
if (!isManualTask && automations.automations.length === 0) {
412+
if (!hasConnection && !justConnected) {
413+
return (
414+
<>
415+
{draftsStrip}
416+
<BrowserEvidenceEmptyState
417+
isStartingAuth={context.isStartingAuth}
418+
onConnect={() => setConnectOpen(true)}
419+
/>
420+
</>
421+
);
422+
}
423+
413424
return (
414425
<>
415-
<DraftsStrip
416-
drafts={drafts}
417-
profiles={profiles}
418-
onContinue={handleContinueDraft}
419-
onDelete={handleDeleteDraft}
420-
/>
426+
{draftsStrip}
421427
<EmptyWithContextState
422428
onCreateClick={() => setComposer({ open: true, mode: 'create' })}
423429
/>

0 commit comments

Comments
 (0)