From 2b5fe4aa4a805799331e30de93a72c5eaba9683a Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 22 Jun 2026 15:06:27 -0400 Subject: [PATCH] fix(browserbase): disable Stagehand hosted API to unblock connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Unknown error: 400" on Connect came from Stagehand's hosted API (POST /sessions/start) — a separate upstream from the premature-close fix. We create and own the Browserbase session ourselves and only need CDP navigation plus local inference, so the hosted-API round-trip is both unnecessary and the single point of failure for connect, login-check, and evidence runs (all route through createStagehand). Set disableAPI:true so Stagehand skips /sessions/start: the session still resumes over CDP, and extract/act/agent run locally against ANTHROPIC_API_KEY (verified: apiClient is only built inside the !disableAPI guard, and extract/agent fall back to local handlers when it is null). Also forward Stagehand's own error logs into the NestJS logger, because it strips upstream error bodies from its throws — so a future failure surfaces the real reason instead of "Unknown error: 400". Also fix the Trigger.dev maxDuration units for the scheduled runner and orchestrator: they were 1000*60*N (~166h / ~500h), so the intended 10/30-minute safety abort never tripped. maxDuration is in SECONDS. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013zSwXMqVNvWLJBZEot9x12 --- .../browserbase-session.service.spec.ts | 20 +++++++++++++++ .../browserbase-session.service.ts | 25 +++++++++++++++---- .../run-browser-automation.ts | 2 +- .../run-browser-automations-schedule.ts | 2 +- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/api/src/browserbase/browserbase-session.service.spec.ts b/apps/api/src/browserbase/browserbase-session.service.spec.ts index 5a1c986db..de2cc2059 100644 --- a/apps/api/src/browserbase/browserbase-session.service.spec.ts +++ b/apps/api/src/browserbase/browserbase-session.service.spec.ts @@ -238,6 +238,26 @@ describe('BrowserbaseSessionService', () => { expect(close).toHaveBeenCalledTimes(1); }); + it('runs Stagehand with the hosted API disabled', 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); + + 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. + expect(StagehandCtor).toHaveBeenCalledWith( + expect.objectContaining({ + env: 'BROWSERBASE', + browserbaseSessionID: 'session_1', + disableAPI: true, + }), + ); + }); + it('navigateToUrl returns the friendly unavailable message when init keeps failing', async () => { const service = new BrowserbaseSessionService(); jest diff --git a/apps/api/src/browserbase/browserbase-session.service.ts b/apps/api/src/browserbase/browserbase-session.service.ts index df858f8ef..24485d719 100644 --- a/apps/api/src/browserbase/browserbase-session.service.ts +++ b/apps/api/src/browserbase/browserbase-session.service.ts @@ -167,11 +167,18 @@ export class BrowserbaseSessionService { async createStagehand(sessionId: string): Promise { const Stagehand = await this.loadStagehand(); - // Stagehand.init() resumes the session by calling the Browserbase API on - // its own internal SDK client (outside getBrowserbase()), so transient - // upstream failures there — e.g. "Premature close" — bypass the per-call - // retry that wraps our direct SDK calls. Retry init the same way, closing - // any half-initialized instance between attempts so we don't leak it. + // 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: " 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. return this.withBrowserbaseRetry({ operationName: 'stagehand initialization', operation: async () => { @@ -180,11 +187,19 @@ export class BrowserbaseSessionService { apiKey: process.env.BROWSERBASE_API_KEY, projectId: this.getProjectId(), browserbaseSessionID: sessionId, + disableAPI: true, model: { modelName: STAGEHAND_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, }, verbose: 1, + logger: (line) => { + if ((line.level ?? 1) === 0) { + this.logger.error( + `Stagehand[${line.category ?? 'log'}]: ${line.message}`, + ); + } + }, }); try { diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index 05dbf1d02..eb649e6c6 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -188,7 +188,7 @@ async function sendTaskStatusChangeEmails(params: { */ export const runBrowserAutomation = task({ id: 'run-browser-automation', - maxDuration: 1000 * 60 * 10, // 10 minutes per automation + maxDuration: 60 * 10, // 10 minutes per automation — Trigger.dev maxDuration is in SECONDS queue: { concurrencyLimit: browserAutomationConcurrencyLimit(), }, diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts index d3aceb930..f06b42d88 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts @@ -93,7 +93,7 @@ export function limitAutomationBatch< export const browserAutomationsSchedule = schedules.task({ id: 'browser-automations-schedule', cron: '0 5 * * *', // Daily at 5:00 AM UTC - maxDuration: 1000 * 60 * 30, // 30 minutes + maxDuration: 60 * 30, // 30 minutes — Trigger.dev maxDuration is in SECONDS run: async (payload) => { logger.info('Starting daily browser automations orchestrator', { scheduledAt: payload.timestamp,