From c510699141a97daa55a9e5c758ab1bf03fd695f5 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 11:29:38 -0400 Subject: [PATCH 01/26] docs(browserbase): add SALE-45 screenshot automation improvements spec Design doc covering three fixes: baked-in audit overlay (timestamp, source URL, auditor requirement), stable redirect endpoint to replace stale presigned URLs on "Open full size", and evaluation-error repair. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...reenshot-automation-improvements-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/specs/2026-04-22-sale-45-screenshot-automation-improvements-design.md diff --git a/docs/specs/2026-04-22-sale-45-screenshot-automation-improvements-design.md b/docs/specs/2026-04-22-sale-45-screenshot-automation-improvements-design.md new file mode 100644 index 0000000000..c693f8c47c --- /dev/null +++ b/docs/specs/2026-04-22-sale-45-screenshot-automation-improvements-design.md @@ -0,0 +1,129 @@ +# SALE-45 — Screenshot Automation Improvements + +**Ticket**: https://linear.app/compai/issue/SALE-45 +**Date**: 2026-04-22 +**Branch**: `mariano/sale-45-screenshot-automation-feature-improvements` + +## Goals + +Three independent fixes to the browser-automation screenshot feature: + +1. **Audit overlay baked into the image** — timestamp, source URL, auditor requirement burned into the PNG/JPEG so a screenshot retains its provenance when downloaded, exported, or pasted into an audit report. +2. **Repair "Access denied" when opening the full-size screenshot** — the presigned URL embedded in the run payload is stale by the time a reviewer clicks "Open full size". +3. **Diagnose and repair the "evaluation error" state** surfaced in the UI. + +All three ship in the same branch and the same PR; they touch the same module and the audit team is waiting on the full set. + +## Non-goals + +- Swapping the screenshot transport from `page.screenshot()` to CDP `Page.captureScreenshot` (a latent nice-to-have, not part of this ticket). +- Changing Browserbase session lifecycle, Stagehand model, or eval prompt architecture. +- Redesigning `RunItem.tsx` layout beyond the minimum to hook into the new URL flow. + +## Architecture + +### Backend — capture, composite, upload + +**File**: `apps/api/src/browserbase/browserbase.service.ts` (currently 918 lines — creation of a new helper module is required to stay under the 300-line rule). + +New module: `apps/api/src/browserbase/screenshot-overlay.ts` + +``` +renderOverlay({ buffer, instruction, sourceUrl, capturedAt }): Promise +``` + +Uses `sharp` (already in `package.json`) to: + +1. Decode the incoming JPEG buffer, read its width/height. +2. Render an overlay strip (SVG → PNG via `sharp.composite`) that sits on the **bottom** of the screenshot, increasing the image height by ~88px. +3. Banner content (three rows, ~12–13px text, dark `#0A0A0A` background, white text, left-aligned): + - **Row 1** (bold): `Auditor requirement: ` — truncated with ellipsis at ~120 chars + - **Row 2**: `Captured: 2026-04-22 14:32:07 UTC` + - **Row 3**: `Source: https://final.page.url/after/redirects` +4. Re-encode as JPEG @ quality 85 and return. + +Integration point: `executeAutomation` at line ~805 already has a post-capture buffer. After the existing `page.screenshot()`, call `renderOverlay()` against the decoded buffer using: + +- `instruction` — from the `BrowserAutomation.instruction` field already available in the run context +- `sourceUrl` — `await page.url()` captured just before `page.screenshot()` (reflects the post-navigation URL, not just the requested `targetUrl`) +- `capturedAt` — `new Date()` in UTC, formatted `yyyy-MM-dd HH:mm:ss 'UTC'` via `date-fns-tz` + +Upload path is unchanged (`uploadScreenshot` at line 837) — the overlaid buffer just flows through as before. + +### Backend — fresh-URL redirect endpoint + +New controller endpoint in `apps/api/src/browserbase/browserbase.controller.ts`: + +``` +GET /v1/browser-automations/runs/:runId/screenshot + → 302 redirect to a freshly minted presigned URL (TTL 1h) +``` + +- Guards: `@UseGuards(HybridAuthGuard, PermissionGuard)` + `@RequirePermission('task', 'read')` (screenshots are scoped to tasks, reuse the existing task:read permission to keep auditors/admins/etc. aligned with who can already see runs) +- Service method: `BrowserbaseService.getScreenshotRedirectUrl(runId, organizationId)` — loads the run, scopes to org, verifies `screenshotUrl` (key) is present, mints fresh presigned URL, returns the URL string. Controller issues the redirect with `@Res()` + `res.redirect(302, url)`. +- 404 if run doesn't exist / doesn't belong to org; 404 if no screenshot yet (so a link never dangles). + +The existing `getAutomationsWithPresignedUrls` keeps inlining a presigned URL for the preview `` thumbnail (needed for the inline thumbnail to render without a second round-trip). The **full-size link** switches to the stable redirect URL. + +### Frontend — stable "Open full size" link + +**File**: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx` + +Change the `Open full size` anchor's `href` from `run.screenshotUrl` (presigned, ages out) to the stable redirect URL: + +``` +/api/v1/browser-automations/runs/{run.id}/screenshot +``` + +Use `apiClient.buildUrl()` or the existing helper so it resolves correctly across environments (cross-subdomain cookie flow handles auth). The inline `` preview keeps using `run.screenshotUrl` for now (it renders while the page is still fresh; if it fails, the existing `imageError` fallback already shows a "Try direct link" fallback that we'll also switch to the redirect URL). + +### Evaluation error — investigation + fix + +The error in the ticket screenshot shows a failed evaluation state in RunItem's error pane. Root cause is currently unknown — needs repro before the fix. Plan: + +1. Enumerate all code paths that set `BrowserAutomationRun.error` and `evaluationStatus = fail` (service.ts + any queue consumer). +2. Reproduce by running a known-working automation against a stable URL; if it doesn't fail, reproduce with the URL from the ticket if available, or with a URL that triggers a consent wall. +3. The likely candidates are: + - Stagehand extract timing out → shows up as evaluation error with a long stack + - Browserbase session expiring mid-run → already has handling (`isNoPage`), but may not classify correctly + - `page.screenshot()` throwing when the page closed between navigation and capture +4. Once reproduced, fix at the source and surface a short, user-readable reason on `evaluationReason` rather than raw stack text. Leave the full error on `run.error` for debugging. + +This task is scoped as "investigate + minimal fix + user-facing-message cleanup". If the investigation reveals a larger architectural issue (e.g., needs a queue-retry policy), it gets its own ticket — we don't expand scope here. + +## Data model + +No schema changes. Existing fields are sufficient: + +- `BrowserAutomationRun.screenshotUrl` — S3 key (unchanged) +- `BrowserAutomationRun.evaluationStatus`, `evaluationReason`, `error` — already in use + +## Error handling + +- **Overlay rendering failure** (malformed source image, sharp throws): log the error, upload the original un-overlaid buffer rather than failing the entire run. An audit screenshot without an overlay is strictly better than no screenshot at all. +- **Presigned URL redirect 404**: returned when run is missing, not in the caller's org, or has no screenshot key. The `` falls back to the existing `imageError` branch in `RunItem.tsx`. +- **Overlay adds too much height**: clamp banner to a fixed 88px regardless of source image width; long instruction text is truncated with ellipsis. Tests include a 4000px-wide and a 400px-wide image. + +## Testing + +All new behavior is unit-tested per the project rule "Every new feature MUST include tests". + +- **API (Jest)** — `apps/api/src/browserbase/` + - `screenshot-overlay.spec.ts` — asserts output image is a valid JPEG, height equals `source.height + 88`, overlay bytes present by sampling pixels in the banner region, truncation of long instruction text, handling of unicode/URL-encoded source URLs. + - `browserbase.controller.spec.ts` — new redirect endpoint: 302 with fresh URL for authorized caller, 404 for cross-org run, 404 for run without screenshot, 401 for unauthenticated. + - `browserbase.service.spec.ts` — `getScreenshotRedirectUrl` happy path + cross-org scope check. +- **App (Vitest)** — `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/` + - `RunItem.test.tsx` — "Open full size" anchor points at `/api/v1/.../screenshot`, not a signed S3 URL. "Try direct link" fallback also points at the redirect URL. Inline `` still uses presigned preview URL. + +## Rollout + +- Deploy API + app together. The redirect endpoint is new — if only the app deploys first, the old presigned URL keeps working (worst case: another hour of the old stale-URL bug). If only the API deploys first, the endpoint is unused. +- No feature flag needed; this is strictly additive on the backend and the frontend change is a one-line `href` swap. +- No data migration needed. + +## Out of scope (explicit) + +- Changing eval prompt / eval model +- Switching to CDP screenshots +- Adding a user-settable "overlay preset" (single banner style for v1 — parametrize later if requested) +- Bumping the inline-thumbnail URL TTL (current 1h is fine because the thumbnail is rendered immediately after fetch) From 10aba5aaecd630c7aada910cfa3ca79154f76664 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 11:35:54 -0400 Subject: [PATCH 02/26] docs(browserbase): add SALE-45 implementation plan 11-task TDD plan: overlay module + integration, org-scoped redirect service method + controller endpoint, RunItem href swap, eval-error hardening, and final verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...e-45-screenshot-automation-improvements.md | 1093 +++++++++++++++++ 1 file changed, 1093 insertions(+) create mode 100644 docs/plans/2026-04-22-sale-45-screenshot-automation-improvements.md diff --git a/docs/plans/2026-04-22-sale-45-screenshot-automation-improvements.md b/docs/plans/2026-04-22-sale-45-screenshot-automation-improvements.md new file mode 100644 index 0000000000..d84284543a --- /dev/null +++ b/docs/plans/2026-04-22-sale-45-screenshot-automation-improvements.md @@ -0,0 +1,1093 @@ +# SALE-45 — Screenshot Automation Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship three improvements to the browser-automation screenshot feature: (a) bake audit metadata (requirement text, UTC timestamp, source URL) into the screenshot image itself; (b) replace the stale-presigned-URL "Open full size" link with a stable redirect endpoint that mints a fresh URL on click; (c) diagnose and harden the "evaluation error" state. + +**Architecture:** Backend changes live in `apps/api/src/browserbase/` — a new `screenshot-overlay.ts` module (pure sharp-based function), integration into `executeAutomation`, a new service method + controller endpoint for the redirect, and a surgical fix in the eval path. Frontend swaps the `` in `RunItem.tsx` from the presigned S3 URL to the new redirect path and gets vitest coverage. + +**Tech Stack:** NestJS (apps/api), Next.js (apps/app), Prisma, sharp (for image compositing — already in `apps/api/package.json` at `^0.34.5`), date-fns (already present, use `format` + UTC conversion), Jest (API tests), Vitest (app tests). + +**Reference spec:** `docs/specs/2026-04-22-sale-45-screenshot-automation-improvements-design.md` + +--- + +## File Structure + +**New:** +- `apps/api/src/browserbase/screenshot-overlay.ts` — pure function `renderOverlay()` using sharp +- `apps/api/src/browserbase/screenshot-overlay.spec.ts` — Jest unit tests for overlay +- `apps/api/src/browserbase/browserbase.service.spec.ts` — Jest tests for new `getScreenshotRedirectUrl` +- `apps/api/src/browserbase/browserbase.controller.spec.ts` — Jest tests for the new redirect endpoint +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx` — Vitest tests + +**Modify:** +- `apps/api/src/browserbase/browserbase.service.ts` — (a) capture `page.url()` and overlay buffer before returning from `executeAutomation`; (b) add `getScreenshotRedirectUrl` method; (c) eval-error hardening in Task 6. +- `apps/api/src/browserbase/browserbase.controller.ts` — add `GET runs/:runId/screenshot` redirect endpoint. +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx` — swap "Open full size" and "Try direct link" hrefs to the stable redirect path. + +--- + +## Task 1: Write failing tests for screenshot overlay module + +**Files:** +- Create: `apps/api/src/browserbase/screenshot-overlay.spec.ts` + +- [ ] **Step 1: Create the test file** + +```typescript +// apps/api/src/browserbase/screenshot-overlay.spec.ts +import sharp from 'sharp'; +import { renderOverlay, OVERLAY_HEIGHT_PX } from './screenshot-overlay'; + +describe('renderOverlay', () => { + const makeSolidJpeg = async (width = 800, height = 600) => { + return sharp({ + create: { + width, + height, + channels: 3, + background: { r: 240, g: 240, b: 240 }, + }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + }; + + it('adds a bottom banner that increases image height by OVERLAY_HEIGHT_PX', async () => { + const input = await makeSolidJpeg(800, 600); + const out = await renderOverlay({ + buffer: input, + instruction: 'Verify MFA is enforced', + sourceUrl: 'https://github.com/settings/security', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.width).toBe(800); + expect(meta.height).toBe(600 + OVERLAY_HEIGHT_PX); + expect(meta.format).toBe('jpeg'); + }); + + it('preserves non-800 widths (narrow image)', async () => { + const input = await makeSolidJpeg(400, 300); + const out = await renderOverlay({ + buffer: input, + instruction: 'Check', + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.width).toBe(400); + expect(meta.height).toBe(300 + OVERLAY_HEIGHT_PX); + }); + + it('preserves wide widths (4000px)', async () => { + const input = await makeSolidJpeg(4000, 1200); + const out = await renderOverlay({ + buffer: input, + instruction: 'Check', + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.width).toBe(4000); + expect(meta.height).toBe(1200 + OVERLAY_HEIGHT_PX); + }); + + it('paints a dark banner on the bottom (top-left pixel is light source color; bottom-center is dark banner)', async () => { + const input = await makeSolidJpeg(800, 600); + const out = await renderOverlay({ + buffer: input, + instruction: 'Check', + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const raw = await sharp(out).raw().toBuffer({ resolveWithObject: true }); + const { data, info } = raw; + // Top-left pixel: source color, ~240 + const topLeft = { r: data[0], g: data[1], b: data[2] }; + expect(topLeft.r).toBeGreaterThan(200); + // Bottom-center pixel (in the banner region): dark + const bottomRow = info.height - Math.floor(OVERLAY_HEIGHT_PX / 2); + const midCol = Math.floor(info.width / 2); + const idx = (bottomRow * info.width + midCol) * info.channels; + const bottomMid = { r: data[idx], g: data[idx + 1], b: data[idx + 2] }; + expect(bottomMid.r).toBeLessThan(40); + expect(bottomMid.g).toBeLessThan(40); + expect(bottomMid.b).toBeLessThan(40); + }); + + it('truncates very long instruction text without throwing', async () => { + const input = await makeSolidJpeg(800, 600); + const longInstruction = 'a'.repeat(500); + const out = await renderOverlay({ + buffer: input, + instruction: longInstruction, + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.height).toBe(600 + OVERLAY_HEIGHT_PX); + }); + + it('handles unicode in instruction and URL', async () => { + const input = await makeSolidJpeg(800, 600); + const out = await renderOverlay({ + buffer: input, + instruction: 'Vérifier MFA — 🔐', + sourceUrl: 'https://exämple.com/café', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.height).toBe(600 + OVERLAY_HEIGHT_PX); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd apps/api && npx jest src/browserbase/screenshot-overlay --passWithNoTests` +Expected: **FAIL** with `Cannot find module './screenshot-overlay'`. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add apps/api/src/browserbase/screenshot-overlay.spec.ts +git commit -m "test(browserbase): add failing tests for screenshot overlay renderer" +``` + +--- + +## Task 2: Implement screenshot overlay module + +**Files:** +- Create: `apps/api/src/browserbase/screenshot-overlay.ts` + +- [ ] **Step 1: Create the module** + +```typescript +// apps/api/src/browserbase/screenshot-overlay.ts +import sharp from 'sharp'; + +export const OVERLAY_HEIGHT_PX = 88; +const OVERLAY_BG = '#0A0A0A'; +const OVERLAY_TEXT = '#FFFFFF'; +const OVERLAY_MUTED = '#A1A1AA'; +const MAX_INSTRUCTION_CHARS = 120; +const MAX_URL_CHARS = 140; + +export interface RenderOverlayInput { + buffer: Buffer; + instruction: string; + sourceUrl: string; + capturedAt: Date; +} + +/** + * Composite an audit metadata banner onto the bottom of a screenshot. + * The banner adds OVERLAY_HEIGHT_PX to the total image height. + * Failure-mode contract: throws on malformed input; callers should handle and fall back to the raw image. + */ +export async function renderOverlay(input: RenderOverlayInput): Promise { + const { buffer, instruction, sourceUrl, capturedAt } = input; + + const sourceMeta = await sharp(buffer).metadata(); + const width = sourceMeta.width; + if (!width) { + throw new Error('renderOverlay: source image has no width'); + } + + const instructionText = truncate(instruction, MAX_INSTRUCTION_CHARS); + const sourceUrlText = truncate(sourceUrl, MAX_URL_CHARS); + const timestampText = formatUtc(capturedAt); + + const bannerSvg = buildBannerSvg({ + width, + height: OVERLAY_HEIGHT_PX, + instruction: instructionText, + sourceUrl: sourceUrlText, + timestamp: timestampText, + }); + + const bannerBuffer = await sharp(Buffer.from(bannerSvg)) + .png() + .toBuffer(); + + // Extend the source image downward by OVERLAY_HEIGHT_PX and paint the banner there. + const extended = await sharp(buffer) + .extend({ + bottom: OVERLAY_HEIGHT_PX, + background: OVERLAY_BG, + }) + .composite([ + { + input: bannerBuffer, + top: sourceMeta.height ?? 0, + left: 0, + }, + ]) + .jpeg({ quality: 85 }) + .toBuffer(); + + return extended; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return value.slice(0, max - 1) + '…'; +} + +function formatUtc(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const y = date.getUTCFullYear(); + const m = pad(date.getUTCMonth() + 1); + const d = pad(date.getUTCDate()); + const hh = pad(date.getUTCHours()); + const mm = pad(date.getUTCMinutes()); + const ss = pad(date.getUTCSeconds()); + return `${y}-${m}-${d} ${hh}:${mm}:${ss} UTC`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +interface BannerArgs { + width: number; + height: number; + instruction: string; + sourceUrl: string; + timestamp: string; +} + +function buildBannerSvg(args: BannerArgs): string { + const { width, height, instruction, sourceUrl, timestamp } = args; + const padX = 16; + const rowFontSize = 13; + const labelFontSize = 11; + const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + + return ` + + + + + AUDITOR REQUIREMENT + ${escapeXml(instruction)} + + + CAPTURED + ${escapeXml(timestamp)} + + + SOURCE + ${escapeXml(sourceUrl)} + + +`; +} +``` + +- [ ] **Step 2: Run the tests and verify they pass** + +Run: `cd apps/api && npx jest src/browserbase/screenshot-overlay` +Expected: **PASS** — all 6 tests green. + +- [ ] **Step 3: Typecheck** + +Run: `npx turbo run typecheck --filter=@trycompai/api` +Expected: **No errors.** + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/browserbase/screenshot-overlay.ts +git commit -m "feat(browserbase): add screenshot overlay renderer with audit metadata banner" +``` + +--- + +## Task 3: Integrate overlay into `executeAutomation` + +**Files:** +- Modify: `apps/api/src/browserbase/browserbase.service.ts` (around lines 742-835; `executeAutomation` method) + +- [ ] **Step 1: Add the import at the top of the service** + +Modify `apps/api/src/browserbase/browserbase.service.ts`: + +Old (line 13): +```typescript +import { getSignedUrl } from '@/app/s3'; +``` + +New (append after that line): +```typescript +import { getSignedUrl } from '@/app/s3'; +import { renderOverlay } from './screenshot-overlay'; +``` + +- [ ] **Step 2: Capture `page.url()` and apply overlay before returning** + +In `executeAutomation`, replace the block that currently takes the screenshot and returns (currently around lines 803-817). Old: + +```typescript + // Always take a screenshot at the end (no pass/fail criteria gate) + page = await this.ensureActivePage(stagehand); + const screenshot = await page.screenshot({ + type: 'jpeg', + quality: 80, + fullPage: false, + }); + + return { + success: true, + screenshot: screenshot.toString('base64'), + evaluationReason: taskContext + ? `Navigation completed for "${taskContext.title}". Screenshot captured.` + : 'Navigation completed. Screenshot captured.', + }; +``` + +New: + +```typescript + // Always take a screenshot at the end (no pass/fail criteria gate) + page = await this.ensureActivePage(stagehand); + const sourceUrl = page.url(); + const rawScreenshot = await page.screenshot({ + type: 'jpeg', + quality: 80, + fullPage: false, + }); + + let finalBuffer: Buffer = rawScreenshot; + try { + finalBuffer = await renderOverlay({ + buffer: rawScreenshot, + instruction, + sourceUrl, + capturedAt: new Date(), + }); + } catch (overlayErr) { + this.logger.warn('Screenshot overlay render failed; uploading raw image', { + error: + overlayErr instanceof Error ? overlayErr.message : String(overlayErr), + }); + } + + return { + success: true, + screenshot: finalBuffer.toString('base64'), + evaluationReason: taskContext + ? `Navigation completed for "${taskContext.title}". Screenshot captured.` + : 'Navigation completed. Screenshot captured.', + }; +``` + +- [ ] **Step 3: Typecheck** + +Run: `npx turbo run typecheck --filter=@trycompai/api` +Expected: **No errors.** + +- [ ] **Step 4: Verify existing screenshot tests still pass (if any) and overlay tests still pass** + +Run: `cd apps/api && npx jest src/browserbase --passWithNoTests` +Expected: **PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/browserbase/browserbase.service.ts +git commit -m "feat(browserbase): bake audit overlay into captured screenshots" +``` + +--- + +## Task 4: Write failing test for `getScreenshotRedirectUrl` service method + +**Files:** +- Create: `apps/api/src/browserbase/browserbase.service.spec.ts` + +- [ ] **Step 1: Create the service spec with failing tests** + +```typescript +// apps/api/src/browserbase/browserbase.service.spec.ts +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { BrowserbaseService } from './browserbase.service'; + +jest.mock('@db', () => ({ + db: { + browserAutomationRun: { + findUnique: jest.fn(), + }, + }, +})); + +jest.mock('@/app/s3', () => ({ + getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/signed'), +})); + +import { db } from '@db'; + +describe('BrowserbaseService.getScreenshotRedirectUrl', () => { + let service: BrowserbaseService; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [BrowserbaseService], + }).compile(); + service = moduleRef.get(BrowserbaseService); + }); + + it('returns a freshly minted presigned URL for an in-scope run', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg', + automation: { task: { organizationId: 'org_1' } }, + }); + + const url = await service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }); + + expect(url).toBe('https://s3.example.com/signed'); + expect(db.browserAutomationRun.findUnique).toHaveBeenCalledWith({ + where: { id: 'bar_1' }, + include: { automation: { include: { task: true } } }, + }); + }); + + it('throws NotFoundException when the run does not exist', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + service.getScreenshotRedirectUrl({ + runId: 'bar_missing', + organizationId: 'org_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws NotFoundException when the run belongs to a different org', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: 'browser-automations/org_2/bau_1/bar_1.jpg', + automation: { task: { organizationId: 'org_2' } }, + }); + + await expect( + service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws NotFoundException when the run has no screenshot', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: null, + automation: { task: { organizationId: 'org_1' } }, + }); + + await expect( + service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); +``` + +- [ ] **Step 2: Run and verify it fails** + +Run: `cd apps/api && npx jest src/browserbase/browserbase.service --passWithNoTests` +Expected: **FAIL** with `service.getScreenshotRedirectUrl is not a function`. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add apps/api/src/browserbase/browserbase.service.spec.ts +git commit -m "test(browserbase): add failing test for getScreenshotRedirectUrl" +``` + +--- + +## Task 5: Implement `getScreenshotRedirectUrl` service method + +**Files:** +- Modify: `apps/api/src/browserbase/browserbase.service.ts` (add method after `getPresignedUrl` at line 865) + +- [ ] **Step 1: Add `NotFoundException` to the imports** + +Old (line 1): +```typescript +import { Injectable, Logger } from '@nestjs/common'; +``` + +New: +```typescript +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +``` + +- [ ] **Step 2: Add the method** + +Insert after the existing `getPresignedUrl` method (around line 865), before `getRunWithPresignedUrl`: + +```typescript + /** + * Resolve a run's S3 screenshot key to a freshly signed presigned URL, + * scoped to the caller's organization. Used by the controller's + * GET runs/:runId/screenshot redirect endpoint so that the "Open full size" + * UI link never serves an expired URL. + */ + async getScreenshotRedirectUrl(input: { + runId: string; + organizationId: string; + }): Promise { + const { runId, organizationId } = input; + + const run = await db.browserAutomationRun.findUnique({ + where: { id: runId }, + include: { automation: { include: { task: true } } }, + }); + + if (!run || !run.screenshotUrl) { + throw new NotFoundException('Screenshot not found'); + } + + if (run.automation.task.organizationId !== organizationId) { + throw new NotFoundException('Screenshot not found'); + } + + return this.getPresignedUrl(run.screenshotUrl); + } +``` + +- [ ] **Step 3: Verify the tests pass** + +Run: `cd apps/api && npx jest src/browserbase/browserbase.service` +Expected: **PASS** — all 4 tests green. + +- [ ] **Step 4: Typecheck** + +Run: `npx turbo run typecheck --filter=@trycompai/api` +Expected: **No errors.** + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/browserbase/browserbase.service.ts +git commit -m "feat(browserbase): add getScreenshotRedirectUrl with org scope" +``` + +--- + +## Task 6: Write failing test for the redirect endpoint + +**Files:** +- Create: `apps/api/src/browserbase/browserbase.controller.spec.ts` + +- [ ] **Step 1: Create the controller spec** + +```typescript +// apps/api/src/browserbase/browserbase.controller.spec.ts +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import type { Response } from 'express'; +import { BrowserbaseController } from './browserbase.controller'; +import { BrowserbaseService } from './browserbase.service'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; + +describe('BrowserbaseController.redirectToScreenshot', () => { + let controller: BrowserbaseController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + getScreenshotRedirectUrl: jest.fn(), + } as jest.Mocked>; + + const moduleRef = await Test.createTestingModule({ + controllers: [BrowserbaseController], + providers: [{ provide: BrowserbaseService, useValue: service }], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = moduleRef.get(BrowserbaseController); + }); + + const makeRes = () => { + const res: Partial = { redirect: jest.fn() }; + return res as Response & { redirect: jest.Mock }; + }; + + it('302-redirects to the freshly minted presigned URL', async () => { + service.getScreenshotRedirectUrl.mockResolvedValue( + 'https://s3.example.com/fresh-signed', + ); + const res = makeRes(); + + await controller.redirectToScreenshot('bar_1', 'org_1', res); + + expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({ + runId: 'bar_1', + organizationId: 'org_1', + }); + expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed'); + }); + + it('propagates NotFoundException when the service throws', async () => { + service.getScreenshotRedirectUrl.mockRejectedValue( + new NotFoundException('Screenshot not found'), + ); + const res = makeRes(); + + await expect( + controller.redirectToScreenshot('bar_missing', 'org_1', res), + ).rejects.toBeInstanceOf(NotFoundException); + expect(res.redirect).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd apps/api && npx jest src/browserbase/browserbase.controller` +Expected: **FAIL** — `controller.redirectToScreenshot is not a function`. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add apps/api/src/browserbase/browserbase.controller.spec.ts +git commit -m "test(browserbase): add failing test for screenshot redirect endpoint" +``` + +--- + +## Task 7: Implement the redirect endpoint + +**Files:** +- Modify: `apps/api/src/browserbase/browserbase.controller.ts` + +- [ ] **Step 1: Expand the imports from `@nestjs/common`** + +Old (lines 1-10): +```typescript +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +``` + +New: +```typescript +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Res, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +``` + +- [ ] **Step 2: Add the endpoint at the end of the controller (after `getRunById`)** + +Insert just before the closing `}` of the class: + +```typescript + @Get('runs/:runId/screenshot') + @RequirePermission('task', 'read') + @ApiOperation({ + summary: 'Redirect to a freshly signed screenshot URL', + description: + 'Issues a 302 redirect to a newly signed S3 URL so that "Open full size" links never serve an expired URL.', + }) + @ApiParam({ name: 'runId', description: 'Run ID' }) + @ApiResponse({ status: 302, description: 'Redirect to signed S3 URL' }) + @ApiResponse({ status: 404, description: 'Run or screenshot not found' }) + async redirectToScreenshot( + @Param('runId') runId: string, + @OrganizationId() organizationId: string, + @Res() res: Response, + ): Promise { + const url = await this.browserbaseService.getScreenshotRedirectUrl({ + runId, + organizationId, + }); + res.redirect(302, url); + } +``` + +- [ ] **Step 3: Verify the tests pass** + +Run: `cd apps/api && npx jest src/browserbase/browserbase.controller` +Expected: **PASS** — both tests green. + +- [ ] **Step 4: Typecheck** + +Run: `npx turbo run typecheck --filter=@trycompai/api` +Expected: **No errors.** + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/browserbase/browserbase.controller.ts +git commit -m "feat(browserbase): add GET runs/:runId/screenshot redirect endpoint" +``` + +--- + +## Task 8: Write failing tests for RunItem anchor behavior + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx` + +- [ ] **Step 1: Create the vitest spec** + +```tsx +// apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { RunItem } from './RunItem'; +import type { BrowserAutomationRun } from '../../hooks/types'; + +const baseRun: BrowserAutomationRun = { + id: 'bar_123', + status: 'completed', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + screenshotUrl: 'https://s3.example.com/signed?sig=abc', + evaluationStatus: 'pass', + evaluationReason: 'All good', + error: null, +} as unknown as BrowserAutomationRun; + +describe('RunItem', () => { + it('Open full size anchor points at the stable redirect endpoint, not the signed URL', () => { + render(); + const link = screen.getByRole('link', { name: /open full size/i }); + expect(link.getAttribute('href')).toContain( + '/v1/browserbase/runs/bar_123/screenshot', + ); + expect(link.getAttribute('href')).not.toContain('s3.example.com'); + }); + + it('Try direct link fallback also points at the stable redirect endpoint', () => { + render(); + // Force image error state by firing onError on the + const img = screen.getByAltText('Automation screenshot'); + fireEvent.error(img); + const fallback = screen.getByRole('link', { name: /try direct link/i }); + expect(fallback.getAttribute('href')).toContain( + '/v1/browserbase/runs/bar_123/screenshot', + ); + }); + + it('renders the inline thumbnail using the presigned URL from the run payload', () => { + render(); + const img = screen.getByAltText('Automation screenshot') as HTMLImageElement; + expect(img.src).toContain('s3.example.com'); + }); +}); +``` + +- [ ] **Step 2: Run and verify it fails** + +Run: `cd apps/app && npx vitest run src/app/\\(app\\)/\\[orgId\\]/tasks/\\[taskId\\]/components/browser-automations/RunItem.test.tsx` +Expected: **FAIL** — "Open full size" currently points at the S3 URL. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add "apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx" +git commit -m "test(run-item): add failing test for stable full-size screenshot URL" +``` + +--- + +## Task 9: Update `RunItem` to use the stable redirect URL + +**Files:** +- Modify: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx` + +- [ ] **Step 1: Derive the stable URL near the top of the component** + +Old (lines 16-25): +```tsx +export function RunItem({ run, isLatest }: RunItemProps) { + const [expanded, setExpanded] = useState(isLatest); + const [imageError, setImageError] = useState(false); + + const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true }); + const hasFailed = run.status === 'failed'; + const isCompleted = run.status === 'completed'; + const hasScreenshot = !!run.screenshotUrl; + const evaluationPassed = run.evaluationStatus === 'pass'; + const evaluationFailed = run.evaluationStatus === 'fail'; +``` + +New: +```tsx +export function RunItem({ run, isLatest }: RunItemProps) { + const [expanded, setExpanded] = useState(isLatest); + const [imageError, setImageError] = useState(false); + + const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true }); + const hasFailed = run.status === 'failed'; + const isCompleted = run.status === 'completed'; + const hasScreenshot = !!run.screenshotUrl; + const evaluationPassed = run.evaluationStatus === 'pass'; + const evaluationFailed = run.evaluationStatus === 'fail'; + + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''; + const fullSizeHref = `${apiBase}/v1/browserbase/runs/${run.id}/screenshot`; +``` + +- [ ] **Step 2: Swap the "Open full size" anchor's href** + +Old (line 143): +```tsx + e.stopPropagation()} + > + Open full size +``` + +New: +```tsx + e.stopPropagation()} + > + Open full size +``` + +- [ ] **Step 3: Swap the "Try direct link" fallback anchor's href** + +Old (line 172): +```tsx + e.stopPropagation()} + > + Try direct link +``` + +New: +```tsx + e.stopPropagation()} + > + Try direct link +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: `cd apps/app && npx vitest run src/app/\\(app\\)/\\[orgId\\]/tasks/\\[taskId\\]/components/browser-automations/RunItem.test.tsx` +Expected: **PASS** — all 3 tests green. + +- [ ] **Step 5: Typecheck the app** + +Run: `npx turbo run typecheck --filter=@trycompai/app` +Expected: **No errors.** + +- [ ] **Step 6: Run the design-system audit skill** + +Per `CLAUDE.md`, after any frontend edit run the `audit-design-system` skill on the modified file. If it flags `lucide-react` imports or legacy `@trycompai/ui` usage, migrate them in a follow-up task (do NOT expand scope here — only migrate icons/components that were part of the edited lines). + +- [ ] **Step 7: Commit** + +```bash +git add "apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx" +git commit -m "fix(run-item): point full-size link at stable redirect endpoint" +``` + +--- + +## Task 10: Investigate the evaluation error state + +**Files:** +- Read-only exploration. Update `apps/api/src/browserbase/browserbase.service.ts` as dictated by findings. + +Context: the ticket screenshot shows an `Evaluation Failed` state with an error message. Looking at the current code paths: + +- `runBrowserAutomation` / `executeAutomationOnSession` set `evaluationStatus: result.evaluationStatus`, but `executeAutomation` never populates `evaluationStatus` on its return value (only `evaluationReason`). That means the UI's "Pass"/"Fail" badge is sourced from a field that is never set to `fail` by the current code — so the "evaluation error" in the ticket is almost certainly the raw error message shown in RunItem's error pane (`run.error`), not a proper evaluation-fail signal. + +- Candidates for the error surface: + - `authCheck.isLoggedIn === false` → `error: 'Session expired. Please re-authenticate in browser settings.'` + - `isNoPage` catch → `error: 'Browser session ended before we could capture evidence. Please retry.'` + - Generic catch → raw `err.message` (often a stack-ish Stagehand/Browserbase error) + +- [ ] **Step 1: Grep for every assignment to `evaluationStatus`, `evaluationReason`, and `run.error`** + +Run these and record findings: +```bash +rg -n "evaluationStatus\s*:" apps/api/src/browserbase +rg -n "evaluationReason\s*:" apps/api/src/browserbase +rg -n "error\s*:\s*(err|result\.error)" apps/api/src/browserbase +rg -n "await stagehand\." apps/api/src/browserbase/browserbase.service.ts +``` + +- [ ] **Step 2: Reproduce locally** + +Start the API and app (if the user has local browserbase/anthropic credentials available; otherwise trigger a run against a URL that is likely to fail auth or timeout, e.g. a URL that redirects to login). + +```bash +npx turbo run dev --filter=@trycompai/api & +npx turbo run dev --filter=@trycompai/app & +``` + +Trigger an automation from the UI and watch the run land in `failed` status. + +- [ ] **Step 3: Narrow the user-facing error surface** + +In `executeAutomation`'s generic catch (currently ~line 818-831), the branch that does not match `isNoPage` returns `error: message` — where `message` is the raw thrown-error message. Wrap that with a stable, user-readable message while preserving the raw details in the service logger: + +Old: +```typescript + } catch (err) { + this.logger.error('Failed to execute automation', err); + const message = err instanceof Error ? err.message : String(err); + const isNoPage = + message.includes('awaitActivePage') || + message.includes('no page available') || + message.includes('No page found'); + return { + success: false, + needsReauth: isNoPage ? true : undefined, + error: isNoPage + ? 'Browser session ended before we could capture evidence. Please retry.' + : message, + }; + } +``` + +New: +```typescript + } catch (err) { + this.logger.error('Failed to execute automation', err); + const message = err instanceof Error ? err.message : String(err); + const isNoPage = + message.includes('awaitActivePage') || + message.includes('no page available') || + message.includes('No page found'); + const isTimeout = + message.includes('timeout') || + message.includes('Timeout') || + message.includes('timed out'); + + const userFacing = isNoPage + ? 'Browser session ended before we could capture evidence. Please retry.' + : isTimeout + ? 'Automation timed out before completing. Please retry — if this keeps happening, simplify the instruction or check the target site.' + : 'Automation failed to complete. Please retry — see run error details for specifics.'; + + return { + success: false, + needsReauth: isNoPage ? true : undefined, + error: userFacing, + }; + } +``` + +- [ ] **Step 4: Typecheck + run all browserbase tests** + +Run: +```bash +npx turbo run typecheck --filter=@trycompai/api +cd apps/api && npx jest src/browserbase +``` +Expected: **PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/browserbase/browserbase.service.ts +git commit -m "fix(browserbase): surface user-readable error for timeouts and generic failures" +``` + +--- + +## Task 11: Final verification + +**Files:** none — this is a verification-only task. + +- [ ] **Step 1: Full typecheck across the monorepo** + +Run: +```bash +npx turbo run typecheck --filter=@trycompai/api --filter=@trycompai/app +``` +Expected: **No errors.** + +- [ ] **Step 2: Full test sweep** + +Run: +```bash +cd apps/api && npx jest src/browserbase +cd ../../apps/app && npx vitest run src/app/\\(app\\)/\\[orgId\\]/tasks/\\[taskId\\]/components/browser-automations +``` +Expected: **All green.** + +- [ ] **Step 3: Lint the touched packages** + +Run: `bun run lint` +Expected: **No errors.** + +- [ ] **Step 4: Build the affected packages** + +Run: `bun run --filter '@trycompai/api' build && bun run --filter '@trycompai/app' build` +Expected: **Both succeed.** + +- [ ] **Step 5: Smoke test the redirect endpoint** + +With the dev API running: +```bash +curl -I -b "" http://localhost:3333/v1/browserbase/runs//screenshot +``` +Expected: `HTTP/1.1 302 Found` with a `Location: https://...amazonaws.com/...` header. + +- [ ] **Step 6: Ask the user before pushing** + +Per workflow preferences in memory, do NOT `git push` without explicit user confirmation. Report completion, summarize what shipped, and ask whether to push and open a PR. From ba64f289e64df103da16ac2c5a28a863ef076077 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 12:55:39 -0400 Subject: [PATCH 03/26] test(browserbase): add failing tests for screenshot overlay renderer --- .../browserbase/screenshot-overlay.spec.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 apps/api/src/browserbase/screenshot-overlay.spec.ts diff --git a/apps/api/src/browserbase/screenshot-overlay.spec.ts b/apps/api/src/browserbase/screenshot-overlay.spec.ts new file mode 100644 index 0000000000..65c020c0d6 --- /dev/null +++ b/apps/api/src/browserbase/screenshot-overlay.spec.ts @@ -0,0 +1,106 @@ +// apps/api/src/browserbase/screenshot-overlay.spec.ts +import sharp from 'sharp'; +import { renderOverlay, OVERLAY_HEIGHT_PX } from './screenshot-overlay'; + +describe('renderOverlay', () => { + const makeSolidJpeg = async (width = 800, height = 600) => { + return sharp({ + create: { + width, + height, + channels: 3, + background: { r: 240, g: 240, b: 240 }, + }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + }; + + it('adds a bottom banner that increases image height by OVERLAY_HEIGHT_PX', async () => { + const input = await makeSolidJpeg(800, 600); + const out = await renderOverlay({ + buffer: input, + instruction: 'Verify MFA is enforced', + sourceUrl: 'https://github.com/settings/security', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.width).toBe(800); + expect(meta.height).toBe(600 + OVERLAY_HEIGHT_PX); + expect(meta.format).toBe('jpeg'); + }); + + it('preserves non-800 widths (narrow image)', async () => { + const input = await makeSolidJpeg(400, 300); + const out = await renderOverlay({ + buffer: input, + instruction: 'Check', + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.width).toBe(400); + expect(meta.height).toBe(300 + OVERLAY_HEIGHT_PX); + }); + + it('preserves wide widths (4000px)', async () => { + const input = await makeSolidJpeg(4000, 1200); + const out = await renderOverlay({ + buffer: input, + instruction: 'Check', + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.width).toBe(4000); + expect(meta.height).toBe(1200 + OVERLAY_HEIGHT_PX); + }); + + it('paints a dark banner on the bottom (top-left pixel is light source color; bottom-center is dark banner)', async () => { + const input = await makeSolidJpeg(800, 600); + const out = await renderOverlay({ + buffer: input, + instruction: 'Check', + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const raw = await sharp(out).raw().toBuffer({ resolveWithObject: true }); + const { data, info } = raw; + // Top-left pixel: source color, ~240 + const topLeft = { r: data[0], g: data[1], b: data[2] }; + expect(topLeft.r).toBeGreaterThan(200); + // Bottom-center pixel (in the banner region): dark + const bottomRow = info.height - Math.floor(OVERLAY_HEIGHT_PX / 2); + const midCol = Math.floor(info.width / 2); + const idx = (bottomRow * info.width + midCol) * info.channels; + const bottomMid = { r: data[idx], g: data[idx + 1], b: data[idx + 2] }; + expect(bottomMid.r).toBeLessThan(40); + expect(bottomMid.g).toBeLessThan(40); + expect(bottomMid.b).toBeLessThan(40); + }); + + it('truncates very long instruction text without throwing', async () => { + const input = await makeSolidJpeg(800, 600); + const longInstruction = 'a'.repeat(500); + const out = await renderOverlay({ + buffer: input, + instruction: longInstruction, + sourceUrl: 'https://example.com', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.height).toBe(600 + OVERLAY_HEIGHT_PX); + }); + + it('handles unicode in instruction and URL', async () => { + const input = await makeSolidJpeg(800, 600); + const out = await renderOverlay({ + buffer: input, + instruction: 'Vérifier MFA — 🔐', + sourceUrl: 'https://exämple.com/café', + capturedAt: new Date('2026-04-22T14:32:07Z'), + }); + const meta = await sharp(out).metadata(); + expect(meta.height).toBe(600 + OVERLAY_HEIGHT_PX); + }); +}); From 2a1666bf1b030f13eb524fe96c356cb41b3235b3 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 12:56:35 -0400 Subject: [PATCH 04/26] feat(browserbase): add screenshot overlay renderer with audit metadata banner --- .../api/src/browserbase/screenshot-overlay.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/api/src/browserbase/screenshot-overlay.ts diff --git a/apps/api/src/browserbase/screenshot-overlay.ts b/apps/api/src/browserbase/screenshot-overlay.ts new file mode 100644 index 0000000000..e75ce2fc0c --- /dev/null +++ b/apps/api/src/browserbase/screenshot-overlay.ts @@ -0,0 +1,119 @@ +// apps/api/src/browserbase/screenshot-overlay.ts +import sharp from 'sharp'; + +export const OVERLAY_HEIGHT_PX = 88; +const OVERLAY_BG = '#0A0A0A'; +const OVERLAY_TEXT = '#FFFFFF'; +const OVERLAY_MUTED = '#A1A1AA'; +const MAX_INSTRUCTION_CHARS = 120; +const MAX_URL_CHARS = 140; + +export interface RenderOverlayInput { + buffer: Buffer; + instruction: string; + sourceUrl: string; + capturedAt: Date; +} + +export async function renderOverlay(input: RenderOverlayInput): Promise { + const { buffer, instruction, sourceUrl, capturedAt } = input; + + const sourceMeta = await sharp(buffer).metadata(); + const width = sourceMeta.width; + if (!width) { + throw new Error('renderOverlay: source image has no width'); + } + + const instructionText = truncate(instruction, MAX_INSTRUCTION_CHARS); + const sourceUrlText = truncate(sourceUrl, MAX_URL_CHARS); + const timestampText = formatUtc(capturedAt); + + const bannerSvg = buildBannerSvg({ + width, + height: OVERLAY_HEIGHT_PX, + instruction: instructionText, + sourceUrl: sourceUrlText, + timestamp: timestampText, + }); + + const bannerBuffer = await sharp(Buffer.from(bannerSvg)) + .png() + .toBuffer(); + + const extended = await sharp(buffer) + .extend({ + bottom: OVERLAY_HEIGHT_PX, + background: OVERLAY_BG, + }) + .composite([ + { + input: bannerBuffer, + top: sourceMeta.height ?? 0, + left: 0, + }, + ]) + .jpeg({ quality: 85 }) + .toBuffer(); + + return extended; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return value.slice(0, max - 1) + '…'; +} + +function formatUtc(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const y = date.getUTCFullYear(); + const m = pad(date.getUTCMonth() + 1); + const d = pad(date.getUTCDate()); + const hh = pad(date.getUTCHours()); + const mm = pad(date.getUTCMinutes()); + const ss = pad(date.getUTCSeconds()); + return `${y}-${m}-${d} ${hh}:${mm}:${ss} UTC`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +interface BannerArgs { + width: number; + height: number; + instruction: string; + sourceUrl: string; + timestamp: string; +} + +function buildBannerSvg(args: BannerArgs): string { + const { width, height, instruction, sourceUrl, timestamp } = args; + const padX = 16; + const rowFontSize = 13; + const labelFontSize = 11; + const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + + return ` + + + + + AUDITOR REQUIREMENT + ${escapeXml(instruction)} + + + CAPTURED + ${escapeXml(timestamp)} + + + SOURCE + ${escapeXml(sourceUrl)} + + +`; +} From 067261a1639d5b95ee71f1f965423e744bb4753a Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:03:22 -0400 Subject: [PATCH 05/26] fix(browserbase): add overlay JSDoc, height guard, and XML control-char strip Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/browserbase/screenshot-overlay.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/api/src/browserbase/screenshot-overlay.ts b/apps/api/src/browserbase/screenshot-overlay.ts index e75ce2fc0c..61a9bfe242 100644 --- a/apps/api/src/browserbase/screenshot-overlay.ts +++ b/apps/api/src/browserbase/screenshot-overlay.ts @@ -15,14 +15,24 @@ export interface RenderOverlayInput { capturedAt: Date; } +/** + * Composite an audit metadata banner onto the bottom of a screenshot. + * The banner adds OVERLAY_HEIGHT_PX to the total image height. + * Failure-mode contract: throws on malformed input; callers should handle + * and fall back to the raw image. + */ export async function renderOverlay(input: RenderOverlayInput): Promise { const { buffer, instruction, sourceUrl, capturedAt } = input; const sourceMeta = await sharp(buffer).metadata(); const width = sourceMeta.width; + const height = sourceMeta.height; if (!width) { throw new Error('renderOverlay: source image has no width'); } + if (!height) { + throw new Error('renderOverlay: source image has no height'); + } const instructionText = truncate(instruction, MAX_INSTRUCTION_CHARS); const sourceUrlText = truncate(sourceUrl, MAX_URL_CHARS); @@ -48,7 +58,7 @@ export async function renderOverlay(input: RenderOverlayInput): Promise .composite([ { input: bannerBuffer, - top: sourceMeta.height ?? 0, + top: height, left: 0, }, ]) @@ -75,7 +85,9 @@ function formatUtc(date: Date): string { } function escapeXml(value: string): string { - return value + // Strip XML 1.0 illegal control chars (keep tab 0x09, LF 0x0A, CR 0x0D) + const stripped = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); + return stripped .replace(/&/g, '&') .replace(//g, '>') From 426e8c87cb1d3468d88b72d386f350872a01c3e9 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:04:49 -0400 Subject: [PATCH 06/26] feat(browserbase): bake audit overlay into captured screenshots --- .../src/browserbase/browserbase.service.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 2cfb919f80..b1db480c78 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -11,6 +11,7 @@ import { S3Client, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@/app/s3'; +import { renderOverlay } from './screenshot-overlay'; const BROWSER_WIDTH = 1440; const BROWSER_HEIGHT = 900; @@ -802,15 +803,31 @@ export class BrowserbaseService { // Always take a screenshot at the end (no pass/fail criteria gate) page = await this.ensureActivePage(stagehand); - const screenshot = await page.screenshot({ + const sourceUrl = page.url(); + const rawScreenshot = await page.screenshot({ type: 'jpeg', quality: 80, fullPage: false, }); + let finalBuffer: Buffer = rawScreenshot; + try { + finalBuffer = await renderOverlay({ + buffer: rawScreenshot, + instruction, + sourceUrl, + capturedAt: new Date(), + }); + } catch (overlayErr) { + this.logger.warn('Screenshot overlay render failed; uploading raw image', { + error: + overlayErr instanceof Error ? overlayErr.message : String(overlayErr), + }); + } + return { success: true, - screenshot: screenshot.toString('base64'), + screenshot: finalBuffer.toString('base64'), evaluationReason: taskContext ? `Navigation completed for "${taskContext.title}". Screenshot captured.` : 'Navigation completed. Screenshot captured.', From 5963a2cb6aa627c9c7ae6ccd6aba91f52f8aa213 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:08:25 -0400 Subject: [PATCH 07/26] test(browserbase): add failing test for getScreenshotRedirectUrl --- .../browserbase/browserbase.service.spec.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/api/src/browserbase/browserbase.service.spec.ts diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts new file mode 100644 index 0000000000..967af7482d --- /dev/null +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -0,0 +1,90 @@ +// apps/api/src/browserbase/browserbase.service.spec.ts +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { BrowserbaseService } from './browserbase.service'; + +jest.mock('@db', () => ({ + db: { + browserAutomationRun: { + findUnique: jest.fn(), + }, + }, +})); + +jest.mock('@/app/s3', () => ({ + getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/signed'), +})); + +import { db } from '@db'; + +describe('BrowserbaseService.getScreenshotRedirectUrl', () => { + let service: BrowserbaseService; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [BrowserbaseService], + }).compile(); + service = moduleRef.get(BrowserbaseService); + }); + + it('returns a freshly minted presigned URL for an in-scope run', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg', + automation: { task: { organizationId: 'org_1' } }, + }); + + const url = await service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }); + + expect(url).toBe('https://s3.example.com/signed'); + expect(db.browserAutomationRun.findUnique).toHaveBeenCalledWith({ + where: { id: 'bar_1' }, + include: { automation: { include: { task: true } } }, + }); + }); + + it('throws NotFoundException when the run does not exist', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + service.getScreenshotRedirectUrl({ + runId: 'bar_missing', + organizationId: 'org_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws NotFoundException when the run belongs to a different org', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: 'browser-automations/org_2/bau_1/bar_1.jpg', + automation: { task: { organizationId: 'org_2' } }, + }); + + await expect( + service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws NotFoundException when the run has no screenshot', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: null, + automation: { task: { organizationId: 'org_1' } }, + }); + + await expect( + service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); From 0d9f8e85ce7bc4e1f946916859d4f4fc6298fe6e Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:09:01 -0400 Subject: [PATCH 08/26] feat(browserbase): add getScreenshotRedirectUrl with org scope --- .../src/browserbase/browserbase.service.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index b1db480c78..38b12f9cd4 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import Browserbase from '@browserbasehq/sdk'; // Lazy-imported in createStagehand() to avoid Node v25 crash // (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it) @@ -881,6 +881,34 @@ export class BrowserbaseService { return getSignedUrl(this.s3Client, command, { expiresIn }); } + /** + * Resolve a run's S3 screenshot key to a freshly signed presigned URL, + * scoped to the caller's organization. Used by the controller's + * GET runs/:runId/screenshot redirect endpoint so that the "Open full size" + * UI link never serves an expired URL. + */ + async getScreenshotRedirectUrl(input: { + runId: string; + organizationId: string; + }): Promise { + const { runId, organizationId } = input; + + const run = await db.browserAutomationRun.findUnique({ + where: { id: runId }, + include: { automation: { include: { task: true } } }, + }); + + if (!run || !run.screenshotUrl) { + throw new NotFoundException('Screenshot not found'); + } + + if (run.automation.task.organizationId !== organizationId) { + throw new NotFoundException('Screenshot not found'); + } + + return this.getPresignedUrl(run.screenshotUrl); + } + async getRunWithPresignedUrl(runId: string) { const run = await db.browserAutomationRun.findUnique({ where: { id: runId }, From 03b14a8378444022c2fadeefbec99d573c4e1630 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:10:30 -0400 Subject: [PATCH 09/26] test(browserbase): add failing test for screenshot redirect endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../browserbase.controller.spec.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apps/api/src/browserbase/browserbase.controller.spec.ts diff --git a/apps/api/src/browserbase/browserbase.controller.spec.ts b/apps/api/src/browserbase/browserbase.controller.spec.ts new file mode 100644 index 0000000000..903fffacfd --- /dev/null +++ b/apps/api/src/browserbase/browserbase.controller.spec.ts @@ -0,0 +1,89 @@ +// apps/api/src/browserbase/browserbase.controller.spec.ts +jest.mock('@db', () => ({ + db: {}, + Prisma: { + PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + } + }, + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { + api: { + getSession: jest.fn(), + }, + }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import type { Response } from 'express'; +import { BrowserbaseController } from './browserbase.controller'; +import { BrowserbaseService } from './browserbase.service'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; + +describe('BrowserbaseController.redirectToScreenshot', () => { + let controller: BrowserbaseController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + getScreenshotRedirectUrl: jest.fn(), + } as jest.Mocked>; + + const moduleRef = await Test.createTestingModule({ + controllers: [BrowserbaseController], + providers: [{ provide: BrowserbaseService, useValue: service }], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = moduleRef.get(BrowserbaseController); + }); + + const makeRes = () => { + const res: Partial = { redirect: jest.fn() }; + return res as Response & { redirect: jest.Mock }; + }; + + it('302-redirects to the freshly minted presigned URL', async () => { + service.getScreenshotRedirectUrl.mockResolvedValue( + 'https://s3.example.com/fresh-signed', + ); + const res = makeRes(); + + await controller.redirectToScreenshot('bar_1', 'org_1', res); + + expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({ + runId: 'bar_1', + organizationId: 'org_1', + }); + expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed'); + }); + + it('propagates NotFoundException when the service throws', async () => { + service.getScreenshotRedirectUrl.mockRejectedValue( + new NotFoundException('Screenshot not found'), + ); + const res = makeRes(); + + await expect( + controller.redirectToScreenshot('bar_missing', 'org_1', res), + ).rejects.toBeInstanceOf(NotFoundException); + expect(res.redirect).not.toHaveBeenCalled(); + }); +}); From a66150ff6f1249b38a84e3bd759b646fcb81fa8d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:11:05 -0400 Subject: [PATCH 10/26] feat(browserbase): add GET runs/:runId/screenshot redirect endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../src/browserbase/browserbase.controller.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/api/src/browserbase/browserbase.controller.ts b/apps/api/src/browserbase/browserbase.controller.ts index cac2b7aef1..8d89ede35d 100644 --- a/apps/api/src/browserbase/browserbase.controller.ts +++ b/apps/api/src/browserbase/browserbase.controller.ts @@ -6,8 +6,10 @@ import { Param, Patch, Post, + Res, UseGuards, } from '@nestjs/common'; +import type { Response } from 'express'; import { ApiOperation, ApiParam, @@ -371,4 +373,26 @@ export class BrowserbaseController { runId, )) as BrowserAutomationRunResponseDto | null; } + + @Get('runs/:runId/screenshot') + @RequirePermission('task', 'read') + @ApiOperation({ + summary: 'Redirect to a freshly signed screenshot URL', + description: + 'Issues a 302 redirect to a newly signed S3 URL so that "Open full size" links never serve an expired URL.', + }) + @ApiParam({ name: 'runId', description: 'Run ID' }) + @ApiResponse({ status: 302, description: 'Redirect to signed S3 URL' }) + @ApiResponse({ status: 404, description: 'Run or screenshot not found' }) + async redirectToScreenshot( + @Param('runId') runId: string, + @OrganizationId() organizationId: string, + @Res() res: Response, + ): Promise { + const url = await this.browserbaseService.getScreenshotRedirectUrl({ + runId, + organizationId, + }); + res.redirect(302, url); + } } From 4d153887768a441a09956c9c43524bb4ef00250a Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:14:20 -0400 Subject: [PATCH 11/26] test(run-item): add failing test for stable full-size screenshot URL Co-Authored-By: Claude Sonnet 4.6 --- .../browser-automations/RunItem.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx new file mode 100644 index 0000000000..d318867672 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx @@ -0,0 +1,43 @@ +// apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { RunItem } from './RunItem'; +import type { BrowserAutomationRun } from '../../hooks/types'; + +const baseRun: BrowserAutomationRun = { + id: 'bar_123', + status: 'completed', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + screenshotUrl: 'https://s3.example.com/signed?sig=abc', + evaluationStatus: 'pass', + evaluationReason: 'All good', + error: null, +} as unknown as BrowserAutomationRun; + +describe('RunItem', () => { + it('Open full size anchor points at the stable redirect endpoint, not the signed URL', () => { + render(); + const link = screen.getByRole('link', { name: /open full size/i }); + expect(link.getAttribute('href')).toContain( + '/v1/browserbase/runs/bar_123/screenshot', + ); + expect(link.getAttribute('href')).not.toContain('s3.example.com'); + }); + + it('Try direct link fallback also points at the stable redirect endpoint', () => { + render(); + const img = screen.getByAltText('Automation screenshot'); + fireEvent.error(img); + const fallback = screen.getByRole('link', { name: /try direct link/i }); + expect(fallback.getAttribute('href')).toContain( + '/v1/browserbase/runs/bar_123/screenshot', + ); + }); + + it('renders the inline thumbnail using the presigned URL from the run payload', () => { + render(); + const img = screen.getByAltText('Automation screenshot') as HTMLImageElement; + expect(img.src).toContain('s3.example.com'); + }); +}); From 769c603744258bb442986c1cd3eff8af45616607 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:15:43 -0400 Subject: [PATCH 12/26] fix(run-item): point full-size link at stable redirect endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../[taskId]/components/browser-automations/RunItem.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx index 04bba2e035..0f556e0871 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx @@ -24,6 +24,9 @@ export function RunItem({ run, isLatest }: RunItemProps) { const evaluationPassed = run.evaluationStatus === 'pass'; const evaluationFailed = run.evaluationStatus === 'fail'; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''; + const fullSizeHref = `${apiBase}/v1/browserbase/runs/${run.id}/screenshot`; + // Determine overall status: failed run, or completed but evaluation failed const hasIssue = hasFailed || evaluationFailed; const statusColor = hasIssue @@ -140,7 +143,7 @@ export function RunItem({ run, isLatest }: RunItemProps) {

Screenshot

Screenshot unavailable

Date: Wed, 22 Apr 2026 13:17:23 -0400 Subject: [PATCH 13/26] fix(browserbase): surface user-readable error for timeouts and generic failures Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/browserbase/browserbase.service.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 38b12f9cd4..c6f6546a36 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -839,12 +839,21 @@ export class BrowserbaseService { message.includes('awaitActivePage') || message.includes('no page available') || message.includes('No page found'); + const isTimeout = + message.includes('timeout') || + message.includes('Timeout') || + message.includes('timed out'); + + const userFacing = isNoPage + ? 'Browser session ended before we could capture evidence. Please retry.' + : isTimeout + ? 'Automation timed out before completing. Please retry — if this keeps happening, simplify the instruction or check the target site.' + : 'Automation failed to complete. Please retry — see run error details for specifics.'; + return { success: false, needsReauth: isNoPage ? true : undefined, - error: isNoPage - ? 'Browser session ended before we could capture evidence. Please retry.' - : message, + error: userFacing, }; } finally { await this.safeCloseStagehand(stagehand); From 84f9e3ab8ac24d3e8f914c6720700f4884799044 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:19:26 -0400 Subject: [PATCH 14/26] test(browserbase): add failing tests for toRunErrorMessage helper Co-Authored-By: Claude Sonnet 4.6 --- .../browserbase/run-error-formatter.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/api/src/browserbase/run-error-formatter.spec.ts diff --git a/apps/api/src/browserbase/run-error-formatter.spec.ts b/apps/api/src/browserbase/run-error-formatter.spec.ts new file mode 100644 index 0000000000..86a0ed895a --- /dev/null +++ b/apps/api/src/browserbase/run-error-formatter.spec.ts @@ -0,0 +1,45 @@ +import { toRunErrorMessage } from './run-error-formatter'; + +describe('toRunErrorMessage', () => { + it('classifies an isNoPage error with needsReauth=true', () => { + const err = new Error('No Page found for awaitActivePage'); + const result = toRunErrorMessage(err); + expect(result.needsReauth).toBe(true); + expect(result.userFacing).toContain('Browser session ended'); + }); + + it('classifies a timeout error without needsReauth', () => { + const err = new Error('operation timed out after 30s'); + const result = toRunErrorMessage(err); + expect(result.needsReauth).toBe(false); + expect(result.userFacing).toContain('timed out'); + }); + + it('uppercase Timeout also matches', () => { + const err = new Error('Timeout exceeded'); + const result = toRunErrorMessage(err); + expect(result.userFacing).toContain('timed out'); + }); + + it('falls back to generic message for other errors', () => { + const err = new Error('some very specific Stagehand crash stack'); + const result = toRunErrorMessage(err); + expect(result.needsReauth).toBe(false); + expect(result.userFacing).toBe( + 'Automation failed to complete. Please retry — see run error details for specifics.', + ); + expect(result.userFacing).not.toContain('Stagehand'); + }); + + it('handles non-Error values (string, unknown)', () => { + expect(toRunErrorMessage('raw string').userFacing).toBe( + 'Automation failed to complete. Please retry — see run error details for specifics.', + ); + expect(toRunErrorMessage({ nested: 'object' }).userFacing).toBe( + 'Automation failed to complete. Please retry — see run error details for specifics.', + ); + expect(toRunErrorMessage(null).userFacing).toBe( + 'Automation failed to complete. Please retry — see run error details for specifics.', + ); + }); +}); From 1f3faff5ce663e1ccda4a887cc7bf84779d2885f Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:19:42 -0400 Subject: [PATCH 15/26] feat(browserbase): add toRunErrorMessage helper for user-readable run errors Co-Authored-By: Claude Sonnet 4.6 --- .../src/browserbase/run-error-formatter.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/api/src/browserbase/run-error-formatter.ts diff --git a/apps/api/src/browserbase/run-error-formatter.ts b/apps/api/src/browserbase/run-error-formatter.ts new file mode 100644 index 0000000000..a6d57ba20e --- /dev/null +++ b/apps/api/src/browserbase/run-error-formatter.ts @@ -0,0 +1,40 @@ +export interface RunErrorMessage { + userFacing: string; + needsReauth: boolean; +} + +const SESSION_ENDED_MESSAGE = + 'Browser session ended before we could capture evidence. Please retry.'; +const TIMEOUT_MESSAGE = + 'Automation timed out before completing. Please retry — if this keeps happening, simplify the instruction or check the target site.'; +const GENERIC_MESSAGE = + 'Automation failed to complete. Please retry — see run error details for specifics.'; + +/** + * Translate a thrown error into a short, user-readable message for the + * BrowserAutomationRun.error field. The raw error is still logged upstream + * for server-side debugging. + */ +export function toRunErrorMessage(err: unknown): RunErrorMessage { + const message = err instanceof Error ? err.message : ''; + + const isNoPage = + message.includes('awaitActivePage') || + message.includes('no page available') || + message.includes('No page found'); + + if (isNoPage) { + return { userFacing: SESSION_ENDED_MESSAGE, needsReauth: true }; + } + + const isTimeout = + message.includes('timeout') || + message.includes('Timeout') || + message.includes('timed out'); + + if (isTimeout) { + return { userFacing: TIMEOUT_MESSAGE, needsReauth: false }; + } + + return { userFacing: GENERIC_MESSAGE, needsReauth: false }; +} From 0c226acf1b2878aa018a346696d17234a327da2c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:20:37 -0400 Subject: [PATCH 16/26] refactor(browserbase): use toRunErrorMessage across all run catch blocks Co-Authored-By: Claude Sonnet 4.6 --- .../src/browserbase/browserbase.service.ts | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index c6f6546a36..442458376a 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -12,6 +12,7 @@ import { } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@/app/s3'; import { renderOverlay } from './screenshot-overlay'; +import { toRunErrorMessage } from './run-error-formatter'; const BROWSER_WIDTH = 1440; const BROWSER_HEIGHT = 900; @@ -569,6 +570,7 @@ export class BrowserbaseService { }; } catch (err) { this.logger.error('Failed to execute automation on session', err); + const { userFacing } = toRunErrorMessage(err); await db.browserAutomationRun.update({ where: { id: runId }, @@ -576,13 +578,13 @@ export class BrowserbaseService { status: 'failed', completedAt: new Date(), durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, - error: err instanceof Error ? err.message : 'Unknown error', + error: userFacing, }, }); return { success: false, - error: err instanceof Error ? err.message : 'Unknown error', + error: userFacing, }; } } @@ -720,6 +722,7 @@ export class BrowserbaseService { } } catch (err) { this.logger.error('Failed to run browser automation', err); + const { userFacing } = toRunErrorMessage(err); // Update run as failed await db.browserAutomationRun.update({ @@ -728,14 +731,14 @@ export class BrowserbaseService { status: 'failed', completedAt: new Date(), durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, - error: err instanceof Error ? err.message : 'Unknown error', + error: userFacing, }, }); return { runId: run.id, success: false, - error: err instanceof Error ? err.message : 'Unknown error', + error: userFacing, }; } } @@ -834,25 +837,10 @@ export class BrowserbaseService { }; } catch (err) { this.logger.error('Failed to execute automation', err); - const message = err instanceof Error ? err.message : String(err); - const isNoPage = - message.includes('awaitActivePage') || - message.includes('no page available') || - message.includes('No page found'); - const isTimeout = - message.includes('timeout') || - message.includes('Timeout') || - message.includes('timed out'); - - const userFacing = isNoPage - ? 'Browser session ended before we could capture evidence. Please retry.' - : isTimeout - ? 'Automation timed out before completing. Please retry — if this keeps happening, simplify the instruction or check the target site.' - : 'Automation failed to complete. Please retry — see run error details for specifics.'; - + const { userFacing, needsReauth } = toRunErrorMessage(err); return { success: false, - needsReauth: isNoPage ? true : undefined, + needsReauth: needsReauth ? true : undefined, error: userFacing, }; } finally { From bc19ab1eee568203722ae6a10b291692bf619b43 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:26:47 -0400 Subject: [PATCH 17/26] fix(browserbase): address review findings (needsReauth pass-through, isNoPage predicate, SVG dim safety) Co-Authored-By: Claude Sonnet 4.6 --- .../src/browserbase/browserbase.service.ts | 15 +++++------ .../browserbase/run-error-formatter.spec.ts | 21 +++++++++++++++- .../src/browserbase/run-error-formatter.ts | 25 +++++++++++++------ .../api/src/browserbase/screenshot-overlay.ts | 4 ++- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 442458376a..2cdeef8f22 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -12,7 +12,7 @@ import { } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@/app/s3'; import { renderOverlay } from './screenshot-overlay'; -import { toRunErrorMessage } from './run-error-formatter'; +import { isNoPageError, toRunErrorMessage } from './run-error-formatter'; const BROWSER_WIDTH = 1440; const BROWSER_HEIGHT = 900; @@ -321,12 +321,7 @@ export class BrowserbaseService { username: result.username, }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const isNoPage = - message.includes('awaitActivePage') || - message.includes('no page available') || - message.includes('No page found'); - if (isNoPage) { + if (isNoPageError(err)) { throw new Error( 'Browser session ended before we could verify login status. Please retry.', ); @@ -570,7 +565,7 @@ export class BrowserbaseService { }; } catch (err) { this.logger.error('Failed to execute automation on session', err); - const { userFacing } = toRunErrorMessage(err); + const { userFacing, needsReauth } = toRunErrorMessage(err); await db.browserAutomationRun.update({ where: { id: runId }, @@ -585,6 +580,7 @@ export class BrowserbaseService { return { success: false, error: userFacing, + needsReauth: needsReauth ? true : undefined, }; } } @@ -722,7 +718,7 @@ export class BrowserbaseService { } } catch (err) { this.logger.error('Failed to run browser automation', err); - const { userFacing } = toRunErrorMessage(err); + const { userFacing, needsReauth } = toRunErrorMessage(err); // Update run as failed await db.browserAutomationRun.update({ @@ -739,6 +735,7 @@ export class BrowserbaseService { runId: run.id, success: false, error: userFacing, + needsReauth: needsReauth ? true : undefined, }; } } diff --git a/apps/api/src/browserbase/run-error-formatter.spec.ts b/apps/api/src/browserbase/run-error-formatter.spec.ts index 86a0ed895a..657f790203 100644 --- a/apps/api/src/browserbase/run-error-formatter.spec.ts +++ b/apps/api/src/browserbase/run-error-formatter.spec.ts @@ -1,4 +1,4 @@ -import { toRunErrorMessage } from './run-error-formatter'; +import { isNoPageError, toRunErrorMessage } from './run-error-formatter'; describe('toRunErrorMessage', () => { it('classifies an isNoPage error with needsReauth=true', () => { @@ -43,3 +43,22 @@ describe('toRunErrorMessage', () => { ); }); }); + +describe('isNoPageError', () => { + it('returns true for awaitActivePage', () => { + expect(isNoPageError(new Error('awaitActivePage: no page available'))).toBe(true); + }); + + it('returns true for No page found', () => { + expect(isNoPageError(new Error('No page found for stagehand'))).toBe(true); + }); + + it('returns false for an unrelated error', () => { + expect(isNoPageError(new Error('network reset'))).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isNoPageError('no page available' as unknown)).toBe(false); + expect(isNoPageError(null)).toBe(false); + }); +}); diff --git a/apps/api/src/browserbase/run-error-formatter.ts b/apps/api/src/browserbase/run-error-formatter.ts index a6d57ba20e..88262e6e0e 100644 --- a/apps/api/src/browserbase/run-error-formatter.ts +++ b/apps/api/src/browserbase/run-error-formatter.ts @@ -11,22 +11,31 @@ const GENERIC_MESSAGE = 'Automation failed to complete. Please retry — see run error details for specifics.'; /** - * Translate a thrown error into a short, user-readable message for the - * BrowserAutomationRun.error field. The raw error is still logged upstream - * for server-side debugging. + * Check whether an error was thrown because Browserbase/Stagehand's active page + * went away (session closed, page navigated, CDP died). These strings are tied + * to upstream SDK error text — if the SDK renames them, this predicate needs + * to be updated and callers may silently fall through to generic handling. */ -export function toRunErrorMessage(err: unknown): RunErrorMessage { +export function isNoPageError(err: unknown): boolean { const message = err instanceof Error ? err.message : ''; - - const isNoPage = + return ( message.includes('awaitActivePage') || message.includes('no page available') || - message.includes('No page found'); + message.includes('No page found') + ); +} - if (isNoPage) { +/** + * Translate a thrown error into a short, user-readable message for the + * BrowserAutomationRun.error field. The raw error is still logged upstream + * for server-side debugging. + */ +export function toRunErrorMessage(err: unknown): RunErrorMessage { + if (isNoPageError(err)) { return { userFacing: SESSION_ENDED_MESSAGE, needsReauth: true }; } + const message = err instanceof Error ? err.message : ''; const isTimeout = message.includes('timeout') || message.includes('Timeout') || diff --git a/apps/api/src/browserbase/screenshot-overlay.ts b/apps/api/src/browserbase/screenshot-overlay.ts index 61a9bfe242..bdf600526b 100644 --- a/apps/api/src/browserbase/screenshot-overlay.ts +++ b/apps/api/src/browserbase/screenshot-overlay.ts @@ -110,8 +110,10 @@ function buildBannerSvg(args: BannerArgs): string { const labelFontSize = 11; const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + const w = Math.floor(width); + const h = Math.floor(height); return ` - + From 5c0f7e88f8f122b460261529cda1a2fac50742ab Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 22 Apr 2026 13:29:05 -0400 Subject: [PATCH 18/26] refactor(run-item): migrate lucide-react to @trycompai/design-system icons ChevronDown, ExternalLink (as Launch), and ImageIcon now come from @trycompai/design-system/icons (Carbon icons). @trycompai/ui/badge kept since no DS equivalent exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/browser-automations/RunItem.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx index 0f556e0871..32cc1a7f52 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx @@ -3,7 +3,11 @@ import { cn } from '@/lib/utils'; import { Badge } from '@trycompai/ui/badge'; import { formatDistanceToNow } from 'date-fns'; -import { ChevronDown, ExternalLink, Image as ImageIcon } from 'lucide-react'; +import { + ChevronDown, + Image as ImageIcon, + Launch as ExternalLink, +} from '@trycompai/design-system/icons'; import Image from 'next/image'; import { useState } from 'react'; import type { BrowserAutomationRun } from '../../hooks/types'; @@ -82,7 +86,7 @@ export function RunItem({ run, isLatest }: RunItemProps) { <> - + Screenshot @@ -91,8 +95,9 @@ export function RunItem({ run, isLatest }: RunItemProps) {
@@ -150,7 +155,7 @@ export function RunItem({ run, isLatest }: RunItemProps) { onClick={(e) => e.stopPropagation()} > Open full size - +
@@ -169,7 +174,7 @@ export function RunItem({ run, isLatest }: RunItemProps) { {/* Image load error fallback */} {hasScreenshot && imageError && (
- +

Screenshot unavailable

Date: Thu, 23 Apr 2026 18:22:27 -0400 Subject: [PATCH 19/26] fix(browserbase): use shared s3Client so uploads pick up APP_AWS_* creds The service was instantiating its own S3Client without credentials, falling back to the default AWS credential chain which fails locally and in most deployments. Reuse the shared s3Client / BUCKET_NAME from @/app/s3 (same pattern as the rest of the API). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../browserbase/browserbase.service.spec.ts | 2 ++ .../src/browserbase/browserbase.service.ts | 31 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 967af7482d..40d708a0a2 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -13,6 +13,8 @@ jest.mock('@db', () => ({ jest.mock('@/app/s3', () => ({ getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/signed'), + s3Client: { send: jest.fn() }, + BUCKET_NAME: 'test-bucket', })); import { db } from '@db'; diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 2cdeef8f22..4f800e75b8 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -5,12 +5,8 @@ import Browserbase from '@browserbasehq/sdk'; type Stagehand = import('@browserbasehq/stagehand').Stagehand; import { db } from '@db'; import { z } from 'zod'; -import { - GetObjectCommand, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@/app/s3'; +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3'; import { renderOverlay } from './screenshot-overlay'; import { isNoPageError, toRunErrorMessage } from './run-error-formatter'; @@ -35,14 +31,23 @@ const isPrismaUniqueConstraintError = (error: unknown): boolean => { @Injectable() export class BrowserbaseService { private readonly logger = new Logger(BrowserbaseService.name); - private readonly s3Client: S3Client; - private readonly bucketName: string; - constructor() { - this.s3Client = new S3Client({ - region: process.env.AWS_REGION || 'us-east-1', - }); - this.bucketName = process.env.APP_AWS_BUCKET_NAME || 'comp-attachments'; + private get s3Client(): S3Client { + if (!s3Client) { + throw new Error( + 'S3 client not configured — set APP_AWS_ACCESS_KEY_ID, APP_AWS_SECRET_ACCESS_KEY, APP_AWS_REGION, APP_AWS_BUCKET_NAME in apps/api/.env', + ); + } + return s3Client; + } + + private get bucketName(): string { + if (!BUCKET_NAME) { + throw new Error( + 'APP_AWS_BUCKET_NAME is not set — configure S3 credentials in apps/api/.env', + ); + } + return BUCKET_NAME; } private getBrowserbase() { From 3df9a8c0c5f5ad634211f79c51d06e06f11b5b0e Mon Sep 17 00:00:00 2001 From: Mariano Date: Thu, 23 Apr 2026 18:22:29 -0400 Subject: [PATCH 20/26] chore(docs): regenerate openapi.json --- packages/docs/openapi.json | 59 +++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 9efcc80ac9..27f80fe5d9 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -12265,16 +12265,7 @@ }, "post": { "operationId": "ControlTemplateController_create_v1", - "parameters": [ - { - "name": "frameworkId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], + "parameters": [], "requestBody": { "required": true, "content": { @@ -13795,6 +13786,20 @@ "type": "string" } }, + { + "name": "severity", + "required": false, + "in": "query", + "schema": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + } + }, { "name": "area", "required": false, @@ -17664,6 +17669,40 @@ ] } }, + "/v1/browserbase/runs/{runId}/screenshot": { + "get": { + "description": "Issues a 302 redirect to a newly signed S3 URL so that \"Open full size\" links never serve an expired URL.", + "operationId": "BrowserbaseController_redirectToScreenshot_v1", + "parameters": [ + { + "name": "runId", + "required": true, + "in": "path", + "description": "Run ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to signed S3 URL" + }, + "404": { + "description": "Run or screenshot not found" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Redirect to a freshly signed screenshot URL", + "tags": [ + "Browserbase" + ] + } + }, "/v1/task-management/stats": { "get": { "description": "Retrieve task items statistics (total count, counts by status) for a specific entity", From 1e096574b24341a4df92cbab7d65874edbad544d Mon Sep 17 00:00:00 2001 From: Mariano Date: Thu, 23 Apr 2026 18:34:28 -0400 Subject: [PATCH 21/26] feat(browserbase): redesign screenshot overlay with Comp AI branding Left brand pillar with hex logo mark + "Comp AI" wordmark + "AUDIT TRAIL" tag, vertical divider, info column on the right, and a thin brand-green accent stripe at the bottom. Keeps the 88px total height so existing tests remain valid. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/src/browserbase/screenshot-overlay.ts | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/api/src/browserbase/screenshot-overlay.ts b/apps/api/src/browserbase/screenshot-overlay.ts index bdf600526b..04e8805942 100644 --- a/apps/api/src/browserbase/screenshot-overlay.ts +++ b/apps/api/src/browserbase/screenshot-overlay.ts @@ -3,11 +3,19 @@ import sharp from 'sharp'; export const OVERLAY_HEIGHT_PX = 88; const OVERLAY_BG = '#0A0A0A'; -const OVERLAY_TEXT = '#FFFFFF'; -const OVERLAY_MUTED = '#A1A1AA'; +const OVERLAY_SURFACE = '#111113'; +const OVERLAY_BORDER = '#1F1F23'; +const OVERLAY_TEXT = '#FAFAFA'; +const OVERLAY_MUTED = '#8B8B92'; +const OVERLAY_ACCENT = '#22C55E'; +const BRAND_PILLAR_WIDTH = 168; const MAX_INSTRUCTION_CHARS = 120; const MAX_URL_CHARS = 140; +// Comp AI hex logo mark, viewBox 0 0 56 56. Fill is overridden at render time. +const COMP_LOGO_PATH = + 'M41 13.3327L39.3682 12.16L28.5853 4.41866C28.2368 4.16845 27.7675 4.16844 27.419 4.41863L2.41685 22.3661C2.15517 22.5539 2 22.8563 2 23.1784V32.8194C2 33.1415 2.15515 33.4439 2.41681 33.6317L27.4189 51.5813C27.7675 51.8315 28.2368 51.8315 28.5854 51.5812L53.5833 33.6317C53.8449 33.4439 54 33.1415 54 32.8194V23.1784C54 22.8563 53.8448 22.5539 53.5832 22.3661L41 13.3327ZM27.419 9.11825C27.7675 8.86804 28.2368 8.86802 28.5854 9.11822L34.9638 13.6969C35.5198 14.096 35.5195 14.9232 34.9633 15.322L31.9378 17.4913C31.7156 17.6504 31.4167 17.6502 31.1947 17.4908L28.5853 15.6178C28.2368 15.3676 27.7675 15.3676 27.419 15.6178L18.7338 21.8529C18.178 22.2519 18.178 23.0787 18.7339 23.4776L21.1661 25.2235L24.4382 27.5755L27.4188 29.7149C27.7674 29.9651 28.2368 29.965 28.5854 29.7146L37.2698 23.4751C37.8251 23.0761 37.8252 22.25 37.2699 21.8509L35.2116 20.3717C35.0294 20.2407 35.0296 19.9695 35.2121 19.8389L38.7873 17.2754C39.1358 17.0255 39.6049 17.0257 39.9532 17.2758L46.3287 21.8531C46.8844 22.252 46.8844 23.0786 46.3288 23.4777L43.3017 25.6513L28.5875 36.2167C28.239 36.467 27.7696 36.467 27.4211 36.2168L19.9104 30.8253L16.6382 28.4777L12.7026 25.6535L9.67725 23.48C9.12177 23.0809 9.12192 22.2544 9.67753 21.8555L27.419 9.11825Z'; + export interface RenderOverlayInput { buffer: Buffer; instruction: string; @@ -105,28 +113,48 @@ interface BannerArgs { function buildBannerSvg(args: BannerArgs): string { const { width, height, instruction, sourceUrl, timestamp } = args; - const padX = 16; - const rowFontSize = 13; - const labelFontSize = 11; - const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + const rowFontSize = 12; + const labelFontSize = 9; + const fontFamily = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const w = Math.floor(width); const h = Math.floor(height); + const pillarW = Math.min(BRAND_PILLAR_WIDTH, Math.floor(w / 3)); + const infoX = pillarW + 20; + const accentStripeH = 2; + const logoSize = 28; + const logoX = 18; + const logoY = Math.floor((h - accentStripeH - logoSize) / 2); + const brandTextX = logoX + logoSize + 12; + const brandTextY = logoY + 14; + const brandTaglineY = logoY + 27; + return ` - + + + + + + + + + Comp AI + AUDIT TRAIL + - - AUDITOR REQUIREMENT - ${escapeXml(instruction)} + + REQUIREMENT + ${escapeXml(instruction)} - - CAPTURED - ${escapeXml(timestamp)} + + CAPTURED + ${escapeXml(timestamp)} - - SOURCE - ${escapeXml(sourceUrl)} + + SOURCE + ${escapeXml(sourceUrl)} `; From cd3e50a26ba02193ea8b6aec4612ffe909fa0a99 Mon Sep 17 00:00:00 2001 From: Mariano Date: Thu, 23 Apr 2026 18:41:07 -0400 Subject: [PATCH 22/26] feat(browserbase): add screenshot download button Service now accepts a `download` flag that signs the S3 URL with a ContentDisposition: attachment header so the browser downloads the image instead of rendering it inline. Controller exposes the flag via ?download=true on the existing redirect endpoint. RunItem.tsx gets a new Download link next to "Open full size". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../browserbase.controller.spec.ts | 16 +++++++++ .../src/browserbase/browserbase.controller.ts | 5 ++- .../browserbase/browserbase.service.spec.ts | 36 +++++++++++++++++++ .../src/browserbase/browserbase.service.ts | 25 ++++++++++--- .../browser-automations/RunItem.test.tsx | 8 +++++ .../browser-automations/RunItem.tsx | 33 +++++++++++------ 6 files changed, 108 insertions(+), 15 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.controller.spec.ts b/apps/api/src/browserbase/browserbase.controller.spec.ts index 903fffacfd..1713edeffa 100644 --- a/apps/api/src/browserbase/browserbase.controller.spec.ts +++ b/apps/api/src/browserbase/browserbase.controller.spec.ts @@ -71,10 +71,26 @@ describe('BrowserbaseController.redirectToScreenshot', () => { expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({ runId: 'bar_1', organizationId: 'org_1', + download: false, }); expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed'); }); + it('passes download=true to the service when the query param is "true"', async () => { + service.getScreenshotRedirectUrl.mockResolvedValue( + 'https://s3.example.com/fresh-signed-attachment', + ); + const res = makeRes(); + + await controller.redirectToScreenshot('bar_1', 'org_1', res, 'true'); + + expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({ + runId: 'bar_1', + organizationId: 'org_1', + download: true, + }); + }); + it('propagates NotFoundException when the service throws', async () => { service.getScreenshotRedirectUrl.mockRejectedValue( new NotFoundException('Screenshot not found'), diff --git a/apps/api/src/browserbase/browserbase.controller.ts b/apps/api/src/browserbase/browserbase.controller.ts index 8d89ede35d..a0c7047fee 100644 --- a/apps/api/src/browserbase/browserbase.controller.ts +++ b/apps/api/src/browserbase/browserbase.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Query, Res, UseGuards, } from '@nestjs/common'; @@ -379,7 +380,7 @@ export class BrowserbaseController { @ApiOperation({ summary: 'Redirect to a freshly signed screenshot URL', description: - 'Issues a 302 redirect to a newly signed S3 URL so that "Open full size" links never serve an expired URL.', + 'Issues a 302 redirect to a newly signed S3 URL so that "Open full size" links never serve an expired URL. Pass ?download=true to force an attachment download.', }) @ApiParam({ name: 'runId', description: 'Run ID' }) @ApiResponse({ status: 302, description: 'Redirect to signed S3 URL' }) @@ -388,10 +389,12 @@ export class BrowserbaseController { @Param('runId') runId: string, @OrganizationId() organizationId: string, @Res() res: Response, + @Query('download') download?: string, ): Promise { const url = await this.browserbaseService.getScreenshotRedirectUrl({ runId, organizationId, + download: download === 'true' || download === '1', }); res.redirect(302, url); } diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 40d708a0a2..5a06feaf39 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -18,6 +18,7 @@ jest.mock('@/app/s3', () => ({ })); import { db } from '@db'; +import { getSignedUrl } from '@/app/s3'; describe('BrowserbaseService.getScreenshotRedirectUrl', () => { let service: BrowserbaseService; @@ -89,4 +90,39 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { }), ).rejects.toBeInstanceOf(NotFoundException); }); + + it('signs the URL without Content-Disposition when download is falsy', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg', + automation: { task: { organizationId: 'org_1' } }, + }); + + await service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + }); + + const command = (getSignedUrl as jest.Mock).mock.calls[0][1]; + expect(command.input.ResponseContentDisposition).toBeUndefined(); + }); + + it('signs the URL with attachment Content-Disposition when download is true', async () => { + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg', + automation: { task: { organizationId: 'org_1' } }, + }); + + await service.getScreenshotRedirectUrl({ + runId: 'bar_1', + organizationId: 'org_1', + download: true, + }); + + const command = (getSignedUrl as jest.Mock).mock.calls[0][1]; + expect(command.input.ResponseContentDisposition).toBe( + 'attachment; filename="screenshot-bar_1.jpg"', + ); + }); }); diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 4f800e75b8..6550b121f0 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -872,12 +872,18 @@ export class BrowserbaseService { return key; } - async getPresignedUrl(key: string, expiresIn = 3600): Promise { + async getPresignedUrl( + key: string, + options: { expiresIn?: number; responseContentDisposition?: string } = {}, + ): Promise { const command = new GetObjectCommand({ Bucket: this.bucketName, Key: key, + ResponseContentDisposition: options.responseContentDisposition, + }); + return getSignedUrl(this.s3Client, command, { + expiresIn: options.expiresIn ?? 3600, }); - return getSignedUrl(this.s3Client, command, { expiresIn }); } /** @@ -885,12 +891,17 @@ export class BrowserbaseService { * scoped to the caller's organization. Used by the controller's * GET runs/:runId/screenshot redirect endpoint so that the "Open full size" * UI link never serves an expired URL. + * + * When `download` is true, the presigned URL is signed with an + * attachment Content-Disposition so the browser downloads the image + * instead of rendering it inline. */ async getScreenshotRedirectUrl(input: { runId: string; organizationId: string; + download?: boolean; }): Promise { - const { runId, organizationId } = input; + const { runId, organizationId, download } = input; const run = await db.browserAutomationRun.findUnique({ where: { id: runId }, @@ -905,7 +916,13 @@ export class BrowserbaseService { throw new NotFoundException('Screenshot not found'); } - return this.getPresignedUrl(run.screenshotUrl); + const responseContentDisposition = download + ? `attachment; filename="screenshot-${runId}.jpg"` + : undefined; + + return this.getPresignedUrl(run.screenshotUrl, { + responseContentDisposition, + }); } async getRunWithPresignedUrl(runId: string) { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx index d318867672..7b7a79665e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.test.tsx @@ -40,4 +40,12 @@ describe('RunItem', () => { const img = screen.getByAltText('Automation screenshot') as HTMLImageElement; expect(img.src).toContain('s3.example.com'); }); + + it('Download anchor points at the redirect endpoint with ?download=true', () => { + render(); + const link = screen.getByRole('link', { name: /download/i }); + expect(link.getAttribute('href')).toContain( + '/v1/browserbase/runs/bar_123/screenshot?download=true', + ); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx index 32cc1a7f52..24b4d5e3b3 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/RunItem.tsx @@ -5,6 +5,7 @@ import { Badge } from '@trycompai/ui/badge'; import { formatDistanceToNow } from 'date-fns'; import { ChevronDown, + Download, Image as ImageIcon, Launch as ExternalLink, } from '@trycompai/design-system/icons'; @@ -30,6 +31,7 @@ export function RunItem({ run, isLatest }: RunItemProps) { const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''; const fullSizeHref = `${apiBase}/v1/browserbase/runs/${run.id}/screenshot`; + const downloadHref = `${fullSizeHref}?download=true`; // Determine overall status: failed run, or completed but evaluation failed const hasIssue = hasFailed || evaluationFailed; @@ -147,16 +149,27 @@ export function RunItem({ run, isLatest }: RunItemProps) {
Date: Thu, 23 Apr 2026 18:53:04 -0400 Subject: [PATCH 23/26] feat(browserbase): configurable evaluation criteria per automation Users can now set an optional natural-language "Evaluation Criteria" on each BrowserAutomation. When set, runs use stagehand.extract() to inspect the final page and produce a pass/fail verdict with a short reason. When unset, runs just capture a screenshot with no verdict. - schema: add BrowserAutomation.evaluationCriteria (nullable) - service: executeAutomation runs the eval step when criteria present; drops the misleading "Navigation completed..." fallback reason when no criteria is configured - dto: accept evaluationCriteria on create/update - ui: add the field to the config dialog; hide the eval result block in RunItem unless a real pass/fail verdict was produced - tests: cover the null-evaluationStatus UI path Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/browserbase/browserbase.service.ts | 73 ++++++++++++++++--- .../src/browserbase/dto/browserbase.dto.ts | 16 ++++ .../BrowserAutomationConfigDialog.tsx | 29 +++++++- .../browser-automations/RunItem.test.tsx | 13 ++++ .../browser-automations/RunItem.tsx | 12 +-- .../[orgId]/tasks/[taskId]/hooks/types.ts | 1 + .../migration.sql | 2 + .../prisma/schema/browserbase-context.prisma | 5 ++ 8 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 packages/db/prisma/migrations/20260423184624_browser_automation_evaluation_criteria/migration.sql diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 6550b121f0..8e388fbc13 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -21,6 +21,13 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const PENDING_CONTEXT_ID = '__PENDING__'; +/** Empty strings become null; any actual text is trimmed and kept. */ +const normalizeCriteria = (value: string | null | undefined): string | null => { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed.length === 0 ? null : trimmed; +}; + const isPrismaUniqueConstraintError = (error: unknown): boolean => { if (typeof error !== 'object' || error === null) return false; if (!('code' in error)) return false; @@ -345,6 +352,7 @@ export class BrowserbaseService { description?: string; targetUrl: string; instruction: string; + evaluationCriteria?: string; schedule?: string; }) { return db.browserAutomation.create({ @@ -354,6 +362,7 @@ export class BrowserbaseService { description: data.description, targetUrl: data.targetUrl, instruction: data.instruction, + evaluationCriteria: normalizeCriteria(data.evaluationCriteria), schedule: data.schedule, isEnabled: true, // Enable by default so scheduled runs work }, @@ -392,13 +401,20 @@ export class BrowserbaseService { description?: string; targetUrl?: string; instruction?: string; + evaluationCriteria?: string; schedule?: string; isEnabled?: boolean; }, ) { + const { evaluationCriteria, ...rest } = data; return db.browserAutomation.update({ where: { id: automationId }, - data, + data: { + ...rest, + ...(evaluationCriteria !== undefined + ? { evaluationCriteria: normalizeCriteria(evaluationCriteria) } + : {}), + }, }); } @@ -508,6 +524,7 @@ export class BrowserbaseService { { title: automation.task.title, description: automation.task.description, + evaluationCriteria: automation.evaluationCriteria, }, ); @@ -536,7 +553,7 @@ export class BrowserbaseService { }; } - // Upload screenshot to S3 (only taken if evaluation passed) + // Upload screenshot to S3 let screenshotKey: string | undefined; let presignedUrl: string | undefined; if (result.screenshot) { @@ -549,7 +566,8 @@ export class BrowserbaseService { presignedUrl = await this.getPresignedUrl(screenshotKey); } - // Update run as completed + // Update run as completed. Only persist an evaluation verdict when + // the caller's automation had criteria configured. await db.browserAutomationRun.update({ where: { id: runId }, data: { @@ -558,7 +576,7 @@ export class BrowserbaseService { durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, screenshotUrl: screenshotKey, evaluationStatus: result.evaluationStatus ?? null, - evaluationReason: result.evaluationReason ?? 'Screenshot captured', + evaluationReason: result.evaluationReason ?? null, }, }); @@ -650,6 +668,7 @@ export class BrowserbaseService { { title: automation.task.title, description: automation.task.description, + evaluationCriteria: automation.evaluationCriteria, }, ); @@ -690,7 +709,8 @@ export class BrowserbaseService { presignedUrl = await this.getPresignedUrl(screenshotKey); } - // Update run as completed + // Update run as completed. Only persist an evaluation verdict when + // the automation had criteria configured. await db.browserAutomationRun.update({ where: { id: run.id }, data: { @@ -699,7 +719,7 @@ export class BrowserbaseService { durationMs: Date.now() - run.startedAt!.getTime(), screenshotUrl: screenshotKey, evaluationStatus: result.evaluationStatus ?? null, - evaluationReason: result.evaluationReason ?? 'Screenshot captured', + evaluationReason: result.evaluationReason ?? null, }, }); @@ -749,7 +769,11 @@ export class BrowserbaseService { sessionId: string, targetUrl: string, instruction: string, - taskContext?: { title: string; description?: string | null }, + taskContext?: { + title: string; + description?: string | null; + evaluationCriteria?: string | null; + }, ): Promise<{ success: boolean; screenshot?: string; @@ -830,12 +854,41 @@ export class BrowserbaseService { }); } + // Optional evaluation: if the automation was configured with + // natural-language criteria, ask Stagehand to inspect the page and + // produce a pass/fail verdict with a short reason. + let evaluationStatus: 'pass' | 'fail' | undefined; + let evaluationReason: string | undefined; + const criteria = taskContext?.evaluationCriteria?.trim(); + if (criteria) { + try { + const evalSchema = z.object({ + pass: z.boolean(), + reason: z.string(), + }); + const evaluation = (await stagehand.extract( + `You are an auditor reviewing the current page after an automation has finished navigating. Decide whether the page clearly satisfies this criteria. Only return pass=true if the evidence is unambiguously present and visible. If it is ambiguous, missing, or contradicted, return pass=false. Always provide a short reason (max 220 characters).\n\nCriteria: ${criteria}`, + evalSchema as any, + )) as { pass: boolean; reason: string }; + + evaluationStatus = evaluation.pass ? 'pass' : 'fail'; + evaluationReason = evaluation.reason; + } catch (evalErr) { + this.logger.warn( + 'Evaluation step failed; returning screenshot without verdict', + { + error: + evalErr instanceof Error ? evalErr.message : String(evalErr), + }, + ); + } + } + return { success: true, screenshot: finalBuffer.toString('base64'), - evaluationReason: taskContext - ? `Navigation completed for "${taskContext.title}". Screenshot captured.` - : 'Navigation completed. Screenshot captured.', + evaluationStatus, + evaluationReason, }; } catch (err) { this.logger.error('Failed to execute automation', err); diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index f0bfffbf49..00b637cf20 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -82,6 +82,14 @@ export class CreateBrowserAutomationDto { @IsNotEmpty() instruction: string; + @ApiPropertyOptional({ + description: + 'Optional natural-language criteria used to evaluate the automation result. When set, the run gets a pass/fail verdict.', + }) + @IsString() + @IsOptional() + evaluationCriteria?: string; + @ApiPropertyOptional({ description: 'Cron schedule expression' }) @IsString() @IsOptional() @@ -111,6 +119,14 @@ export class UpdateBrowserAutomationDto { @IsOptional() instruction?: string; + @ApiPropertyOptional({ + description: + 'Optional natural-language criteria used to evaluate the automation result. Pass an empty string to clear.', + }) + @IsString() + @IsOptional() + evaluationCriteria?: string; + @ApiPropertyOptional({ description: 'Cron schedule expression' }) @IsString() @IsOptional() diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx index a71c038dee..3b75ff1b0a 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx @@ -23,6 +23,7 @@ const automationConfigSchema = z.object({ name: z.string().trim().min(1, { message: 'Name is required' }), targetUrl: z.string().trim().url({ message: 'Starting URL must be a valid URL' }), instruction: z.string().trim().min(1, { message: 'Instruction is required' }), + evaluationCriteria: z.string().trim().optional().default(''), }); type AutomationConfigFormData = z.infer; @@ -30,7 +31,10 @@ type AutomationConfigFormData = z.infer; interface BrowserAutomationConfigDialogProps { isOpen: boolean; mode: 'create' | 'edit'; - initialValues?: Pick; + initialValues?: Pick< + BrowserAutomation, + 'id' | 'name' | 'targetUrl' | 'instruction' | 'evaluationCriteria' + >; isSaving: boolean; onClose: () => void; onCreate: (data: AutomationConfigFormData) => Promise; @@ -57,6 +61,7 @@ export function BrowserAutomationConfigDialog({ name: '', targetUrl: '', instruction: '', + evaluationCriteria: '', }, }); @@ -68,15 +73,16 @@ export function BrowserAutomationConfigDialog({ name: initialValues.name ?? '', targetUrl: initialValues.targetUrl ?? '', instruction: initialValues.instruction ?? '', + evaluationCriteria: initialValues.evaluationCriteria ?? '', }); return; } - reset({ name: '', targetUrl: '', instruction: '' }); + reset({ name: '', targetUrl: '', instruction: '', evaluationCriteria: '' }); }, [isOpen, mode, initialValues, reset]); const handleClose = () => { - reset({ name: '', targetUrl: '', instruction: '' }); + reset({ name: '', targetUrl: '', instruction: '', evaluationCriteria: '' }); onClose(); }; @@ -157,6 +163,23 @@ export function BrowserAutomationConfigDialog({ )}
+
+ +