diff --git a/CHANGELOG.md b/CHANGELOG.md index 137b1462..d687fdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.16.1.0] - 2026-04-08 + +### Fixed +- Cookie picker no longer leaks the browse server auth token. Previously, opening the cookie picker page exposed the master bearer token in the HTML source, letting any local process extract it and execute arbitrary JavaScript in your browser session. Now uses a one-time code exchange with an HttpOnly session cookie. The token never appears in HTML, URLs, or browser history. (Reported by Horoshi at Vagabond Research, CVSS 7.8) + +## [0.16.0.0] - 2026-04-07 + +### Added +- **Browser data platform.** Six new browse commands that turn gstack browser from "a thing that clicks buttons" into a full scraping and data extraction tool for AI agents. +- `media` command: discover every image, video, and audio element on a page. Returns URLs, dimensions, srcset, lazy-load state, and detects HLS/DASH streams. Filter with `--images`, `--videos`, `--audio`, or scope with a CSS selector. +- `data` command: extract structured data embedded in pages. JSON-LD (product prices, recipes, events), Open Graph, Twitter Cards, and meta tags. One command gives you what used to take 50 lines of DOM scraping. +- `download` command: fetch any URL or `@ref` element to disk using the browser's session cookies. Handles blob URLs via in-page base64 conversion. `--base64` flag returns inline data URI for remote agents. Detects HLS/DASH and tells you to use yt-dlp instead of silently failing. +- `scrape` command: bulk download all media from a page. Combines `media` discovery + `download` in a loop with URL deduplication, configurable limits, and a `manifest.json` for machine consumption. +- `archive` command: save complete pages as MHTML via CDP. One command, full page with all resources. +- `scroll --times N`: automated repeated scrolling for infinite feed content loading. Configurable delay between scrolls with `--wait`. +- `screenshot --base64`: return screenshots as inline data URIs instead of file paths. Eliminates the two-step screenshot-then-file-serve dance for remote agents. +- **Network response body capture.** `network --capture` intercepts API response bodies so agents get structured JSON instead of fragile DOM scraping. Filter by URL pattern (`--filter graphql`), export as JSONL (`--export`), view summary (`--bodies`). 50MB size-capped buffer with automatic eviction. +- `GET /file` endpoint: remote paired agents can now retrieve downloaded files (images, scraped media, screenshots) over HTTP. TEMP_DIR only to prevent project file exfiltration. Bearer token auth, MIME detection, zero-copy streaming via `Bun.file()`. + +### Changed +- Paired agents now get full access by default (read+write+admin+meta). The trust boundary is the pairing ceremony, not the scope. An agent that can click any button doesn't gain meaningful attack surface from also being able to run `js`. Browser-wide destructive commands (stop, restart, disconnect) moved to new `control` scope, still opt-in via `--control`. +- Path validation extracted to shared `path-security.ts` module. Was duplicated across three files with slightly different implementations. Now one source of truth with `validateOutputPath`, `validateReadPath`, and `validateTempPath`. + ## [0.15.16.0] - 2026-04-06 ### Added diff --git a/SKILL.md b/SKILL.md index 3d951a67..94ba826b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -773,11 +773,20 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | Command | Description | |---------|-------------| | `accessibility` | Full ARIA tree | +| `data [--jsonld|--og|--meta|--twitter]` | Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags | | `forms` | Form fields as JSON | | `html [selector]` | innerHTML of selector (throws if not found), or full page HTML if no selector given | | `links` | All links as "text → href" | +| `media [--images|--videos|--audio] [selector]` | All media elements (images, videos, audio) with URLs, dimensions, types | | `text` | Cleaned page text | +### Extraction +| Command | Description | +|---------|-------------| +| `archive [path]` | Save complete page as MHTML via CDP | +| `download [path] [--base64]` | Download URL or media element to disk using browser cookies | +| `scrape [--selector sel] [--dir path] [--limit N]` | Bulk download all media from page. Writes manifest.json | + ### Interaction | Command | Description | |---------|-------------| diff --git a/VERSION b/VERSION index 006a1444..84c82737 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.16.0 +0.16.1.0 diff --git a/browse/SKILL.md b/browse/SKILL.md index 5bc9b02b..420e2b0b 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -665,11 +665,20 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | Command | Description | |---------|-------------| | `accessibility` | Full ARIA tree | +| `data [--jsonld|--og|--meta|--twitter]` | Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags | | `forms` | Form fields as JSON | | `html [selector]` | innerHTML of selector (throws if not found), or full page HTML if no selector given | | `links` | All links as "text → href" | +| `media [--images|--videos|--audio] [selector]` | All media elements (images, videos, audio) with URLs, dimensions, types | | `text` | Cleaned page text | +### Extraction +| Command | Description | +|---------|-------------| +| `archive [path]` | Save complete page as MHTML via CDP | +| `download [path] [--base64]` | Download URL or media element to disk using browser cookies | +| `scrape [--selector sel] [--dir path] [--limit N]` | Bulk download all media from page. Writes manifest.json | + ### Interaction | Command | Description | |---------|-------------| diff --git a/browse/src/cli.ts b/browse/src/cli.ts index bbd5c733..0f6210a2 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -566,7 +566,7 @@ COMMAND REFERENCE: New tab: {"command": "newtab", "args": ["URL"]} SCOPES: ${scopeDesc}. -${scopes.includes('admin') ? '' : `To get admin access (JS, cookies, storage), ask the user to re-pair with --admin.\n`} +${scopes.includes('control') ? '' : `To get browser control access (stop, restart, disconnect), ask the user to re-pair with --control.\n`} TOKEN: Expires ${expiresAt}. Revoke: ask the user to run $B tunnel revoke @@ -591,10 +591,13 @@ function hasFlag(args: string[], flag: string): boolean { async function handlePairAgent(state: ServerState, args: string[]): Promise { const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`; const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim()); - const admin = hasFlag(args, '--admin'); + const control = hasFlag(args, '--control') || hasFlag(args, '--admin'); + const restrict = parseFlag(args, '--restrict'); const localHost = parseFlag(args, '--local'); // Call POST /pair to create a setup key + // Default: full access (read+write+admin+meta). --control adds browser-wide ops. + // --restrict limits: --restrict read (read-only), --restrict "read,write" (no admin) const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, { method: 'POST', headers: { @@ -603,9 +606,9 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise s.trim()) } : {}), }), signal: AbortSignal.timeout(5000), }); diff --git a/browse/src/commands.ts b/browse/src/commands.ts index ceb089f3..eacdf0cd 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -16,6 +16,7 @@ export const READ_COMMANDS = new Set([ 'console', 'network', 'cookies', 'storage', 'perf', 'dialog', 'is', 'inspect', + 'media', 'data', ]); export const WRITE_COMMANDS = new Set([ @@ -24,6 +25,7 @@ export const WRITE_COMMANDS = new Set([ 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', 'style', 'cleanup', 'prettyscreenshot', + 'download', 'scrape', 'archive', ]); export const META_COMMANDS = new Set([ @@ -46,6 +48,7 @@ export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...MET export const PAGE_CONTENT_COMMANDS = new Set([ 'text', 'html', 'links', 'forms', 'accessibility', 'attrs', 'console', 'dialog', + 'media', 'data', ]); /** Wrap output from untrusted-content commands with trust boundary markers */ @@ -70,6 +73,8 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'eval': { category: 'Inspection', description: 'Run JavaScript from file and return result as string (path must be under /tmp or cwd)', usage: 'eval ' }, @@ -100,6 +105,10 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' }, 'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' }, + // Data extraction + 'download': { category: 'Extraction', description: 'Download URL or media element to disk using browser cookies', usage: 'download [path] [--base64]' }, + 'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape [--selector sel] [--dir path] [--limit N]' }, + 'archive': { category: 'Extraction', description: 'Save complete page as MHTML via CDP', usage: 'archive [path]' }, // Visual 'screenshot': { category: 'Visual', description: 'Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)', usage: 'screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]' }, 'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' }, diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 775fc0d0..a78741cc 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -4,20 +4,59 @@ * Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts * (decryption) and cookie-picker-ui.ts (HTML generation). * - * Routes (no auth — localhost-only, accepted risk): - * GET /cookie-picker → serves the picker HTML page - * GET /cookie-picker/browsers → list installed browsers - * GET /cookie-picker/domains → list domains + counts for a browser - * POST /cookie-picker/import → decrypt + import cookies to Playwright - * POST /cookie-picker/remove → clear cookies for domains - * GET /cookie-picker/imported → currently imported domains + counts + * Auth model (post-CVE fix): + * GET /cookie-picker → requires one-time code (?code=) or session cookie + * GET /cookie-picker/browsers → requires Bearer token or session cookie + * GET /cookie-picker/domains → requires Bearer token or session cookie + * POST /cookie-picker/import → requires Bearer token or session cookie + * POST /cookie-picker/remove → requires Bearer token or session cookie + * GET /cookie-picker/imported → requires Bearer token or session cookie + * + * The session cookie (gstack_picker) is isolated from the scoped token system. + * It is NOT valid for /command. This prevents session cookie extraction from + * re-enabling the auth token leak vulnerability. */ +import * as crypto from 'crypto'; import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; import { getCookiePickerHTML } from './cookie-picker-ui'; -// ─── State ────────────────────────────────────────────────────── +// ─── Auth State ───────────────────────────────────────────────── +// One-time codes for the cookie picker UI (code → expiry timestamp). +// Codes are generated by generatePickerCode() and consumed on first use. +const pendingCodes = new Map(); +const CODE_TTL_MS = 30_000; // 30 seconds + +// Session cookies for authenticated picker access (session → expiry timestamp). +// Sessions are created after a valid code exchange and last 1 hour. +const validSessions = new Map(); +const SESSION_TTL_MS = 3_600_000; // 1 hour + +/** Generate a one-time code for opening the cookie picker UI. */ +export function generatePickerCode(): string { + const code = crypto.randomUUID(); + pendingCodes.set(code, Date.now() + CODE_TTL_MS); + return code; +} + +/** Extract session ID from the gstack_picker cookie. */ +function getSessionFromCookie(req: Request): string | null { + const cookie = req.headers.get('cookie'); + if (!cookie) return null; + const match = cookie.match(/gstack_picker=([^;]+)/); + return match ? match[1] : null; +} + +/** Check if a session cookie value is valid and not expired. */ +function isValidSession(session: string): boolean { + const expiry = validSessions.get(session); + if (!expiry) return false; + if (Date.now() > expiry) { validSessions.delete(session); return false; } + return true; +} + +// ─── Domain State ─────────────────────────────────────────────── // Tracks which domains were imported via the picker. // /imported only returns cookies for domains in this Set. // /remove clears from this Set. @@ -71,19 +110,56 @@ export async function handleCookiePickerRoute( } try { - // GET /cookie-picker — serve the picker UI + // GET /cookie-picker — serve the picker UI (requires code or session cookie) if (pathname === '/cookie-picker' && req.method === 'GET') { - const html = getCookiePickerHTML(port, authToken); - return new Response(html, { - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, + const code = url.searchParams.get('code'); + + // Code exchange: validate one-time code, set session cookie, redirect + if (code) { + const expiry = pendingCodes.get(code); + if (!expiry || Date.now() > expiry) { + pendingCodes.delete(code); + return new Response('Invalid or expired code. Re-run cookie-import-browser.', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, + }); + } + pendingCodes.delete(code); // one-time use + const session = crypto.randomUUID(); + validSessions.set(session, Date.now() + SESSION_TTL_MS); + return new Response(null, { + status: 302, + headers: { + 'Location': '/cookie-picker', + 'Set-Cookie': `gstack_picker=${session}; HttpOnly; SameSite=Strict; Path=/cookie-picker; Max-Age=3600`, + 'Cache-Control': 'no-store', + }, + }); + } + + // Session cookie: serve HTML (no auth token inlined) + const session = getSessionFromCookie(req); + if (session && isValidSession(session)) { + const html = getCookiePickerHTML(port); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + // No code, no session: reject + return new Response('Access denied. Open the cookie picker from gstack.', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, }); } - // ─── Auth gate: all data/action routes below require Bearer token ─── - // Auth is mandatory — if authToken is undefined, reject all requests + // ─── Auth gate: all data/action routes below require Bearer token or session cookie ─── const authHeader = req.headers.get('authorization'); - if (!authToken || !authHeader || authHeader !== `Bearer ${authToken}`) { + const sessionId = getSessionFromCookie(req); + const hasBearer = !!authToken && !!authHeader && authHeader === `Bearer ${authToken}`; + const hasSession = sessionId !== null && isValidSession(sessionId); + if (!hasBearer && !hasSession) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts index 03089b08..bf151adb 100644 --- a/browse/src/cookie-picker-ui.ts +++ b/browse/src/cookie-picker-ui.ts @@ -7,7 +7,7 @@ * No cookie values exposed anywhere. */ -export function getCookiePickerHTML(serverPort: number, authToken?: string): string { +export function getCookiePickerHTML(serverPort: number): string { const baseUrl = `http://127.0.0.1:${serverPort}`; return ` @@ -341,7 +341,6 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str + + + +
+ + + + Photo 1 + Photo 2 + + + Lazy Image + + + Responsive Image + + + + + + + + + + + diff --git a/browse/test/security-audit-r2.test.ts b/browse/test/security-audit-r2.test.ts index e1ff1d3d..985a53ed 100644 --- a/browse/test/security-audit-r2.test.ts +++ b/browse/test/security-audit-r2.test.ts @@ -17,6 +17,7 @@ const WRITE_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/write-comma const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'), 'utf-8'); const SNAPSHOT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/snapshot.ts'), 'utf-8'); +const PATH_SECURITY_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/path-security.ts'), 'utf-8'); // ─── Helper ───────────────────────────────────────────────────────────────── @@ -159,26 +160,25 @@ describe('Task 2: CSS value validator blocks dangerous patterns', () => { describe('Task 1: validateOutputPath uses realpathSync', () => { describe('source-level checks', () => { - it('meta-commands.ts validateOutputPath contains realpathSync', () => { - const fn = extractFunction(META_SRC, 'validateOutputPath'); + it('path-security.ts validateOutputPath contains realpathSync', () => { + const fn = extractFunction(PATH_SECURITY_SRC, 'validateOutputPath'); expect(fn).toBeTruthy(); expect(fn).toContain('realpathSync'); }); - it('write-commands.ts validateOutputPath contains realpathSync', () => { - const fn = extractFunction(WRITE_SRC, 'validateOutputPath'); - expect(fn).toBeTruthy(); - expect(fn).toContain('realpathSync'); - }); - - it('meta-commands.ts SAFE_DIRECTORIES resolves with realpathSync', () => { - const safeBlock = sliceBetween(META_SRC, 'const SAFE_DIRECTORIES', ';'); + it('path-security.ts SAFE_DIRECTORIES resolves with realpathSync', () => { + const safeBlock = sliceBetween(PATH_SECURITY_SRC, 'const SAFE_DIRECTORIES', ';'); expect(safeBlock).toContain('realpathSync'); }); - it('write-commands.ts SAFE_DIRECTORIES resolves with realpathSync', () => { - const safeBlock = sliceBetween(WRITE_SRC, 'const SAFE_DIRECTORIES', ';'); - expect(safeBlock).toContain('realpathSync'); + it('meta-commands.ts re-exports validateOutputPath from path-security', () => { + expect(META_SRC).toContain("from './path-security'"); + expect(META_SRC).toContain('validateOutputPath'); + }); + + it('write-commands.ts imports validateOutputPath from path-security', () => { + expect(WRITE_SRC).toContain("from './path-security'"); + expect(WRITE_SRC).toContain('validateOutputPath'); }); }); diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index dab03437..48c45987 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -317,4 +317,28 @@ describe('Server auth security', () => { // The ownership check condition must exclude newtab expect(ownershipBlock).toContain("command !== 'newtab'"); }); + + // CVE fix: cookie-picker HTML must NOT inline the auth token. + // getCookiePickerHTML() must not accept an authToken parameter. + test('cookie-picker UI does not accept or inline auth token', () => { + const uiSrc = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-ui.ts'), 'utf-8'); + // Function signature must not include authToken + expect(uiSrc).not.toMatch(/getCookiePickerHTML\([^)]*authToken/); + // No AUTH_TOKEN interpolation in template + expect(uiSrc).not.toContain("AUTH_TOKEN = '${authToken"); + expect(uiSrc).not.toContain("AUTH_TOKEN = '${auth"); + }); + + // CVE fix: cookie-picker route handler uses one-time code exchange, not open access. + test('cookie-picker HTML route requires code or session cookie', () => { + const routeSrc = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-routes.ts'), 'utf-8'); + // Must have code validation + expect(routeSrc).toContain('pendingCodes'); + expect(routeSrc).toContain('validSessions'); + // Must NOT pass authToken to getCookiePickerHTML + expect(routeSrc).not.toMatch(/getCookiePickerHTML\([^)]*authToken/); + // Must set HttpOnly session cookie + expect(routeSrc).toContain('HttpOnly'); + expect(routeSrc).toContain('SameSite=Strict'); + }); }); diff --git a/browse/test/tab-isolation.test.ts b/browse/test/tab-isolation.test.ts index 367d4d49..0d9846db 100644 --- a/browse/test/tab-isolation.test.ts +++ b/browse/test/tab-isolation.test.ts @@ -113,15 +113,15 @@ describe('generateInstructionBlock', () => { expect(block).not.toContain('re-pair with --admin'); }); - it('shows re-pair hint when admin not included', () => { + it('shows re-pair hint when control not included', () => { const block = generateInstructionBlock({ - setupKey: 'gsk_setup_nonadmin', + setupKey: 'gsk_setup_nocontrol', serverUrl: 'https://test.ngrok.dev', - scopes: ['read', 'write'], + scopes: ['read', 'write', 'admin', 'meta'], expiresAt: '2026-04-06T00:00:00Z', }); - expect(block).toContain('re-pair with --admin'); + expect(block).toContain('re-pair with --control'); }); it('includes newtab as step 2 (agents must own their tab)', () => { diff --git a/browse/test/token-registry.test.ts b/browse/test/token-registry.test.ts index e272ea18..07c46a63 100644 --- a/browse/test/token-registry.test.ts +++ b/browse/test/token-registry.test.ts @@ -5,7 +5,7 @@ import { validateToken, checkScope, checkDomain, checkRate, revokeToken, rotateRoot, listTokens, recordCommand, serializeRegistry, restoreRegistry, checkConnectRateLimit, - SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_META, + SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META, } from '../src/token-registry'; describe('token-registry', () => { @@ -25,7 +25,7 @@ describe('token-registry', () => { const info = validateToken('root-token-for-tests'); expect(info).not.toBeNull(); expect(info!.clientId).toBe('root'); - expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta']); + expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta', 'control']); expect(info!.rateLimit).toBe(0); }); }); @@ -324,7 +324,7 @@ describe('token-registry', () => { it('every command in commands.ts is covered by a scope', () => { // Import the command sets to verify coverage const allInScopes = new Set([ - ...SCOPE_READ, ...SCOPE_WRITE, ...SCOPE_ADMIN, ...SCOPE_META, + ...SCOPE_READ, ...SCOPE_WRITE, ...SCOPE_ADMIN, ...SCOPE_CONTROL, ...SCOPE_META, ]); // chain is a special case (checked via meta scope but dispatches subcommands) allInScopes.add('chain'); @@ -339,8 +339,12 @@ describe('token-registry', () => { expect(SCOPE_ADMIN.has('cookies')).toBe(true); expect(SCOPE_ADMIN.has('storage')).toBe(true); expect(SCOPE_ADMIN.has('useragent')).toBe(true); - expect(SCOPE_ADMIN.has('state')).toBe(true); - expect(SCOPE_ADMIN.has('handoff')).toBe(true); + // Browser-wide destructive commands moved to SCOPE_CONTROL + expect(SCOPE_CONTROL.has('state')).toBe(true); + expect(SCOPE_CONTROL.has('handoff')).toBe(true); + expect(SCOPE_CONTROL.has('stop')).toBe(true); + expect(SCOPE_CONTROL.has('restart')).toBe(true); + expect(SCOPE_CONTROL.has('disconnect')).toBe(true); // Verify safe read commands are NOT in admin expect(SCOPE_ADMIN.has('text')).toBe(false); diff --git a/scripts/resolvers/browse.ts b/scripts/resolvers/browse.ts index 9a20447b..ef7e9485 100644 --- a/scripts/resolvers/browse.ts +++ b/scripts/resolvers/browse.ts @@ -13,7 +13,7 @@ export function generateCommandReference(_ctx: TemplateContext): string { // Category display order const categoryOrder = [ - 'Navigation', 'Reading', 'Interaction', 'Inspection', + 'Navigation', 'Reading', 'Extraction', 'Interaction', 'Inspection', 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', ]; diff --git a/test/skill-e2e-bws.test.ts b/test/skill-e2e-bws.test.ts index c1a1be15..acbdf86c 100644 --- a/test/skill-e2e-bws.test.ts +++ b/test/skill-e2e-bws.test.ts @@ -68,7 +68,7 @@ Report the results of each command.`, 5. $B snapshot -i -a -o /tmp/skill-e2e-annotated.png Report what each command returned.`, workingDirectory: tmpDir, - maxTurns: 7, + maxTurns: 9, timeout: 60_000, testName: 'browse-snapshot', runId,