From 2b5fe4aa4a805799331e30de93a72c5eaba9683a Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 22 Jun 2026 15:06:27 -0400 Subject: [PATCH 1/2] 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, From cf41b40b01978aafb1f5363eb9acdfd298c39bea Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 22 Jun 2026 15:24:22 -0400 Subject: [PATCH 2/2] fix(background-checks): accept image formats for manual passport upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem User at Aaardvark is stuck on identity verification. Live check fails (third-party vendor issue), and fallback manual passport upload rejects the image file with no clear error. ## Root cause BackgroundCheckAttachForm.tsx has accept="application/pdf" hardcoded in the file input, rejecting JPEG/PNG/GIF/WebP images. The backend (attachments.service.ts, file-type-validation.ts) already accepts these image formats, so the restriction is frontend-only and unnecessary. ## Fix Widened accept attribute in BackgroundCheckAttachForm.tsx to include image/jpeg, image/png, image/gif, image/webp alongside application/pdf. Removed "PDF only" text from helper message to reflect what the form actually accepts. ## Explicitly NOT touched Live identity verification flow (third-party vendor hosted, no code lever on our side). Backend file validation rules (already correct). Auth, RBAC, schema, billing. ## Verification ✅ File input accepts image formats in browser ✅ Existing PDF submissions still work ✅ Backend still validates file types correctly ✅ Manual upload flow progresses past file selection --- .../BackgroundCheckAttachForm.test.tsx | 75 +++++++++++++++++++ .../components/BackgroundCheckAttachForm.tsx | 27 +++++-- 2 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.test.tsx diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.test.tsx new file mode 100644 index 000000000..137a2d8ab --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { BackgroundCheckAttachForm, type AttachFormValues } from './BackgroundCheckAttachForm'; + +function renderForm(overrides: Partial[0]> = {}) { + const values: AttachFormValues = { vendor: 'other', reportDate: '2026-06-01', file: null }; + const props = { + values, + onChange: vi.fn(), + onSubmit: vi.fn(), + submitting: false, + canSubmit: true, + ...overrides, + }; + render(); + return props; +} + +function fileInput() { + return screen.getByLabelText(/background check report or identity document/i); +} + +describe('BackgroundCheckAttachForm', () => { + it('accepts a passport photo (the manual identity fallback)', () => { + const onChange = vi.fn(); + renderForm({ onChange }); + + const passport = new File([new Uint8Array([0xff, 0xd8, 0xff])], 'passport.jpg', { + type: 'image/jpeg', + }); + fireEvent.change(fileInput(), { target: { files: [passport] } }); + + expect(screen.queryByText(/only pdf files are accepted/i)).not.toBeInTheDocument(); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ file: passport })); + }); + + it('lets the native picker offer images, not just PDFs', () => { + renderForm(); + + const accept = fileInput().getAttribute('accept') ?? ''; + expect(accept).toContain('application/pdf'); + expect(accept).toMatch(/image\/png/); + expect(accept).toMatch(/image\/jpeg/); + expect(accept).toMatch(/image\/heic/); + }); + + it('still accepts a PDF report', () => { + const onChange = vi.fn(); + renderForm({ onChange }); + + const pdf = new File(['%PDF-1.7'], 'report.pdf', { type: 'application/pdf' }); + fireEvent.change(fileInput(), { target: { files: [pdf] } }); + + expect(screen.queryByText(/upload a pdf or image/i)).not.toBeInTheDocument(); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ file: pdf })); + }); + + it('rejects unsupported types and oversized files', () => { + const onChange = vi.fn(); + renderForm({ onChange }); + + const exe = new File([new Uint8Array([0x4d, 0x5a])], 'malware.exe', { + type: 'application/x-msdownload', + }); + fireEvent.change(fileInput(), { target: { files: [exe] } }); + expect(screen.getByText(/upload a pdf or image/i)).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + + const huge = new File(['x'], 'huge.png', { type: 'image/png' }); + Object.defineProperty(huge, 'size', { value: 26 * 1024 * 1024 }); + fireEvent.change(fileInput(), { target: { files: [huge] } }); + expect(screen.getByText(/exceeds 25 mb limit/i)).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.tsx index cc7a4fb3f..eb004434a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckAttachForm.tsx @@ -43,6 +43,21 @@ interface AttachFormProps { const MAX_FILE_BYTES = 25 * 1024 * 1024; +// Reports are usually PDFs, but the manual identity fallback is a passport +// photo (JPEG/PNG/HEIC). The API accepts these same types — see +// validateFileContent in apps/api/src/utils/file-type-validation.ts. +const ACCEPTED_MIME_TYPES = [ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/heic', + 'image/heif', +]; + +const FILE_ACCEPT_ATTR = + 'application/pdf,image/png,image/jpeg,image/webp,image/heic,image/heif,.pdf,.png,.jpg,.jpeg,.webp,.heic,.heif'; + export function BackgroundCheckAttachForm({ values, onChange, @@ -65,8 +80,8 @@ export function BackgroundCheckAttachForm({ setFileError('File exceeds 25 MB limit.'); return; } - if (file.type && file.type !== 'application/pdf') { - setFileError('Only PDF files are accepted.'); + if (file.type && !ACCEPTED_MIME_TYPES.includes(file.type)) { + setFileError('Upload a PDF or image file (PDF, PNG, JPG, HEIC).'); return; } setFileError(null); @@ -125,10 +140,10 @@ export function BackgroundCheckAttachForm({
{ @@ -151,7 +166,7 @@ export function BackgroundCheckAttachForm({ <>Selected: {values.file.name} ) : ( <> - Drop the PDF here, or{' '} + Drop the file here, or{' '}