Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8cb6a1e
chore: merge release v3.87.3 back to main [skip ci]
github-actions[bot] Jun 22, 2026
acdfd1e
fix(browserbase): surface the underlying cause on exhausted retries
tofikwest Jun 22, 2026
e3cdfa1
fix(api): add daily cron to refresh OAuth tokens expiring within 24h
chasprowebdev Jun 23, 2026
7375005
fix(api): ensure only latest versions are considered in refresh-expir…
chasprowebdev Jun 23, 2026
2436165
Merge pull request #3238 from trycompai/tofik/browserbase-surface-ret…
tofikwest Jun 23, 2026
9d30b3e
fix(api): fix mismatched field issue in refresh-expiring-tokens-schedule
chasprowebdev Jun 23, 2026
a1cec9b
fix(browserbase): attach Stagehand over CDP to avoid premature-close …
tofikwest Jun 23, 2026
b7a8e7e
Merge branch 'main' into tofik/browserbase-cdp-connect-url
tofikwest Jun 23, 2026
b320c95
Merge pull request #3241 from trycompai/tofik/browserbase-cdp-connect…
tofikwest Jun 23, 2026
ba85a4e
fix(evidence): include custom roles in assignee visibility and filters
tofikwest Jun 23, 2026
87f0b55
fix(tasks): show custom roles in assignee filter and task overview
tofikwest Jun 23, 2026
0084c43
feat(framework-editor): unified Finder-style frameworks list (FRAME-2…
tofikwest Jun 23, 2026
5ffaca0
Merge branch 'main' into tofik/secdev-role-assigned-to-evidence
tofikwest Jun 23, 2026
7150211
Merge pull request #3242 from trycompai/tofik/secdev-role-assigned-to…
tofikwest Jun 23, 2026
9651f64
Merge branch 'main' into tofik/secdev-role-users-not-appearing
tofikwest Jun 23, 2026
959d5a6
Merge branch 'main' into tofik/frame-20-unified-list
tofikwest Jun 23, 2026
a14888b
Merge pull request #3246 from trycompai/tofik/frame-20-unified-list
tofikwest Jun 23, 2026
d465300
Merge branch 'main' into tofik/secdev-role-users-not-appearing
tofikwest Jun 23, 2026
90442f1
Merge pull request #3243 from trycompai/tofik/secdev-role-users-not-a…
tofikwest Jun 23, 2026
7738842
Merge branch 'main' into chas/cs-544-refresh-token
tofikwest Jun 23, 2026
dac2fb5
Merge pull request #3239 from trycompai/chas/cs-544-refresh-token
tofikwest Jun 23, 2026
6493443
fix(attachments): accept PDFs with a leading BOM/whitespace before %PDF
tofikwest Jun 23, 2026
f09399d
Merge branch 'main' into fix/pdf-header-within-1kb
tofikwest Jun 23, 2026
8e02ddb
Merge pull request #3251 from trycompai/fix/pdf-header-within-1kb
tofikwest Jun 23, 2026
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
58 changes: 52 additions & 6 deletions apps/api/src/browserbase/browserbase-session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ describe('BrowserbaseSessionService', () => {
expect(createContext).toHaveBeenCalledTimes(3);
});

it('includes the underlying cause in the exhausted-retry message', async () => {
jest.useFakeTimers();
const service = new BrowserbaseSessionService();
const createContext = jest.fn().mockRejectedValue(prematureCloseError());
jest
.spyOn(service, 'getBrowserbase')
.mockReturnValue(mockBrowserbaseClient({ createContext }));

const promise = service.createBrowserbaseContext().catch((error) => error);
await jest.advanceTimersByTimeAsync(1_000);
const error = await promise;

expect(error).toBeInstanceOf(ServiceUnavailableException);
expect(error.message).toContain('Premature close');
});

it('preserves non-retryable Browserbase failures', async () => {
const service = new BrowserbaseSessionService();
const browserbaseError = Object.assign(
Expand Down Expand Up @@ -178,6 +194,25 @@ describe('BrowserbaseSessionService', () => {
expect(debugSession).toHaveBeenCalledTimes(2);
});

it('resolves the session connect URL via the identity-encoded client', async () => {
jest.useFakeTimers();
const service = new BrowserbaseSessionService();
const retrieveSession = jest
.fn()
.mockRejectedValueOnce(prematureCloseError())
.mockResolvedValueOnce({ connectUrl: 'wss://connect.browserbase.test/s1' });
jest
.spyOn(service, 'getBrowserbase')
.mockReturnValue(mockBrowserbaseClient({ retrieveSession }));

const promise = service.getSessionConnectUrl('session_1');
await jest.advanceTimersByTimeAsync(250);

await expect(promise).resolves.toBe('wss://connect.browserbase.test/s1');
expect(retrieveSession).toHaveBeenCalledTimes(2);
expect(retrieveSession).toHaveBeenCalledWith('session_1');
});

it('retries transient Stagehand init failures', async () => {
jest.useFakeTimers();
const service = new BrowserbaseSessionService();
Expand All @@ -188,6 +223,9 @@ describe('BrowserbaseSessionService', () => {
const close = jest.fn().mockResolvedValue(undefined);
const StagehandCtor = mockStagehandClass({ init, close });
jest.spyOn(service, 'loadStagehand').mockResolvedValue(StagehandCtor);
jest
.spyOn(service, 'getSessionConnectUrl')
.mockResolvedValue('wss://connect.browserbase.test/s1');

const promise = service.createStagehand('session_1');
await jest.advanceTimersByTimeAsync(250);
Expand All @@ -207,6 +245,9 @@ describe('BrowserbaseSessionService', () => {
jest
.spyOn(service, 'loadStagehand')
.mockResolvedValue(mockStagehandClass({ init, close }));
jest
.spyOn(service, 'getSessionConnectUrl')
.mockResolvedValue('wss://connect.browserbase.test/s1');

const promise = service.createStagehand('session_1');
const expectation = expect(promise).rejects.toBeInstanceOf(
Expand All @@ -229,6 +270,9 @@ describe('BrowserbaseSessionService', () => {
jest
.spyOn(service, 'loadStagehand')
.mockResolvedValue(mockStagehandClass({ init, close }));
jest
.spyOn(service, 'getSessionConnectUrl')
.mockResolvedValue('wss://connect.browserbase.test/s1');

await expect(service.createStagehand('session_1')).rejects.toBe(
sessionNotFound,
Expand All @@ -238,22 +282,24 @@ describe('BrowserbaseSessionService', () => {
expect(close).toHaveBeenCalledTimes(1);
});

it('runs Stagehand with the hosted API disabled', async () => {
it('attaches Stagehand to the resolved CDP URL instead of resuming via Browserbase', async () => {
const service = new BrowserbaseSessionService();
const init = jest.fn().mockResolvedValue(undefined);
const close = jest.fn().mockResolvedValue(undefined);
const StagehandCtor = mockStagehandClass({ init, close });
jest.spyOn(service, 'loadStagehand').mockResolvedValue(StagehandCtor);
jest
.spyOn(service, 'getSessionConnectUrl')
.mockResolvedValue('wss://connect.browserbase.test/s1');

await service.createStagehand('session_1');

// disableAPI:true skips Stagehand's hosted POST /sessions/start (the source
// of "Unknown error: 400"); the session still resumes over CDP.
// env:'LOCAL' + cdpUrl attaches over CDP and avoids Stagehand's own
// bb.sessions.retrieve (the "Premature close" source).
expect(StagehandCtor).toHaveBeenCalledWith(
expect.objectContaining({
env: 'BROWSERBASE',
browserbaseSessionID: 'session_1',
disableAPI: true,
env: 'LOCAL',
localBrowserLaunchOptions: { cdpUrl: 'wss://connect.browserbase.test/s1' },
}),
);
});
Expand Down
49 changes: 29 additions & 20 deletions apps/api/src/browserbase/browserbase-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ export class BrowserbaseSessionService {
return session.contextId;
}

async getSessionConnectUrl(sessionId: string): Promise<string> {
const session = await this.withBrowserbaseRetry({
operationName: 'session connect URL lookup',
operation: () => this.getBrowserbase().sessions.retrieve(sessionId),
});
if (!session.connectUrl) {
throw new Error('Browserbase session is missing a connect URL.');
}
return session.connectUrl;
}

private async withBrowserbaseRetry<T>({
operation,
operationName,
Expand Down Expand Up @@ -143,7 +154,9 @@ export class BrowserbaseSessionService {
attempt,
error: getBrowserbaseErrorText(error),
});
throw browserbaseUnavailableException();
// Surface the underlying cause in the message so an exhausted retry
// is diagnosable from the UI/response, not just the server logs.
throw browserbaseUnavailableException(getBrowserbaseErrorText(error));
}

this.logger.warn(`Browserbase ${operationName} failed; retrying`, {
Expand All @@ -167,27 +180,25 @@ export class BrowserbaseSessionService {
async createStagehand(sessionId: string): Promise<Stagehand> {
const Stagehand = await this.loadStagehand();

// We create and own the Browserbase session ourselves, and this feature only
// needs CDP navigation plus local inference. Stagehand's default hosted-API
// mode adds a POST /sessions/start round-trip that is unnecessary here and is
// the source of opaque "Unknown error: <status>" failures. disableAPI:true
// skips it: the session still resumes over CDP and extract/act/agent run
// locally against ANTHROPIC_API_KEY.
//
// init() still performs a Browserbase API round-trip to resume the session,
// whose transient failures — e.g. "Premature close" — bypass the retry that
// wraps our direct SDK calls, so retry init too, closing any half-initialized
// instance between attempts to avoid leaking it. Stagehand strips upstream
// error bodies from its throws, so forward its error logs into our logger.
// Resolve the CDP connect URL ourselves with our identity-encoded client.
// Stagehand's BROWSERBASE mode would instead call bb.sessions.retrieve on its
// OWN Browserbase client, which lacks our accept-encoding:identity header and
// so fails deterministically with "Premature close" (response decompression
// mishandling) in our runtime — the same failure the identity header already
// fixes for our own calls. Attaching via env:'LOCAL' + cdpUrl makes Stagehand
// connect straight to the session over CDP without that call; extract/act/
// agent then run locally against ANTHROPIC_API_KEY.
const cdpUrl = await this.getSessionConnectUrl(sessionId);

// A transient CDP attach can still fail; retry init, closing any
// half-initialized instance between attempts to avoid leaking it. Stagehand
// strips upstream error bodies from its throws, so forward its error logs.
return this.withBrowserbaseRetry({
operationName: 'stagehand initialization',
operation: async () => {
const stagehand = new Stagehand({
env: 'BROWSERBASE',
apiKey: process.env.BROWSERBASE_API_KEY,
projectId: this.getProjectId(),
browserbaseSessionID: sessionId,
disableAPI: true,
env: 'LOCAL',
localBrowserLaunchOptions: { cdpUrl },
model: {
modelName: STAGEHAND_MODEL,
apiKey: process.env.ANTHROPIC_API_KEY,
Expand All @@ -206,8 +217,6 @@ export class BrowserbaseSessionService {
await stagehand.init();
return stagehand;
} catch (error) {
// keepAlive:true means close() will not end the Browserbase session,
// so the next attempt can resume the same sessionId.
await this.safeCloseStagehand(stagehand);
throw error;
}
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/browserbase/browserbase-upstream-error.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ describe('browserbase upstream errors', () => {
'Browserbase is temporarily unavailable. Please retry in a moment.',
);
});

it('appends the underlying cause when provided', () => {
const error = browserbaseUnavailableException('Premature close');

expect(error.message).toBe(
'Browserbase is temporarily unavailable. Please retry in a moment. (Premature close)',
);
});
});
6 changes: 4 additions & 2 deletions apps/api/src/browserbase/browserbase-upstream-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export const isRetryableBrowserbaseUpstreamError = (
return RETRYABLE_MESSAGE_PARTS.some((part) => message.includes(part));
};

export const browserbaseUnavailableException = () =>
export const browserbaseUnavailableException = (detail?: string) =>
new ServiceUnavailableException(
'Browserbase is temporarily unavailable. Please retry in a moment.',
detail
? `Browserbase is temporarily unavailable. Please retry in a moment. (${detail})`
: 'Browserbase is temporarily unavailable. Please retry in a moment.',
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { db } from '@db';
import { requestValidCredentials } from './ensure-valid-credentials';
import { refreshExpiringTokensSchedule } from './refresh-expiring-tokens-schedule';

jest.mock('@db', () => ({
db: {
integrationConnection: { findMany: jest.fn() },
},
}));

jest.mock('@trigger.dev/sdk', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
schedules: {
task: (config: unknown) => config,
},
}));

jest.mock('./ensure-valid-credentials', () => ({
requestValidCredentials: jest.fn(),
}));

describe('refreshExpiringTokensSchedule', () => {
const nowMs = Date.parse('2026-04-24T00:00:00.000Z');
const lookaheadMs = 24 * 60 * 60 * 1000;

beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(nowMs);
(requestValidCredentials as jest.Mock).mockResolvedValue({ success: true });
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

it('refreshes only connections whose latest credential version expires soon', async () => {
const connectionWithOldVersionExpiringSoon = {
id: 'conn_old_soon',
providerSlug: 'example',
organizationId: 'org_1',
organization: { id: 'org_1', name: 'Org 1' },
credentialVersions: [
{ expiresAt: new Date('2026-04-26T00:00:00.000Z') },
{ expiresAt: new Date('2026-04-24T12:00:00.000Z') },
],
};

const connectionWithLatestExpiringSoon = {
id: 'conn_latest_soon',
providerSlug: 'example',
organizationId: 'org_2',
organization: { id: 'org_2', name: 'Org 2' },
credentialVersions: [{ expiresAt: new Date('2026-04-24T12:00:00.000Z') }],
};

(db.integrationConnection.findMany as jest.Mock).mockResolvedValue([
connectionWithOldVersionExpiringSoon,
connectionWithLatestExpiringSoon,
]);

const result = await refreshExpiringTokensSchedule.run({
timestamp: new Date(nowMs).toISOString(),
lastTimestamp: null,
} as any);

expect(result.refreshed).toBe(1);
expect(requestValidCredentials).toHaveBeenCalledTimes(1);
expect(requestValidCredentials).toHaveBeenCalledWith({
apiUrl: expect.any(String),
connectionId: 'conn_latest_soon',
organizationId: 'org_2',
forceRefresh: true,
});
});

it('skips connections whose latest version is not expiring soon', async () => {
const connectionLatestValid = {
id: 'conn_latest_valid',
providerSlug: 'example',
organizationId: 'org_3',
organization: { id: 'org_3', name: 'Org 3' },
credentialVersions: [{ expiresAt: new Date('2026-04-25T12:00:00.000Z') }],
};

(db.integrationConnection.findMany as jest.Mock).mockResolvedValue([
connectionLatestValid,
]);

const result = await refreshExpiringTokensSchedule.run({
timestamp: new Date(nowMs).toISOString(),
lastTimestamp: null,
} as any);

expect(result.refreshed).toBe(0);
expect(requestValidCredentials).not.toHaveBeenCalled();
});
});
Loading
Loading