From 7c904ef0fd6295af60b10f697c75c7d322116f75 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 14:06:46 -0800 Subject: [PATCH] Add browser workflow reliability primitives and guardrails --- config/default.yaml | 11 +- src/config/schema.test.ts | 21 + src/config/schema.ts | 7 + src/daemon/tools.ts | 25 +- src/tools/builtin/browser/tools.test.ts | 160 ++- src/tools/builtin/browser/tools.ts | 1210 ++++++++++++++++++----- src/tools/policy.ts | 23 +- 7 files changed, 1185 insertions(+), 272 deletions(-) diff --git a/config/default.yaml b/config/default.yaml index 9d3e825..84a48a8 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -238,7 +238,8 @@ models: # default_namespace: default # allowed_namespaces: [] # Empty = allow any namespace; set to restrict access. -# Optional: Browser automation tools (browser.navigate/screenshot/click/type/content/eval/evaluate) +# Optional: Browser automation tools +# (browser.navigate/screenshot/click/type/content/wait_for/assert/extract/checkpoint.save/checkpoint.resume/eval/evaluate) # Requires a local Chrome/Chromium install or a remote CDP endpoint. # browser: # enabled: true @@ -247,6 +248,14 @@ models: # headless: true # max_pages: 5 # default_timeout: 30000 +# # Guardrails: +# # allowed_domains: ["*.example.com"] +# # high_risk_domains: ["bank.example.com"] +# # require_confirmation_for_high_risk: true +# # max_workflow_steps: 120 +# # default_retry_attempts: 1 +# # max_retry_attempts: 5 +# # retry_delay_ms: 250 # # Tool policy reminder: # - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools. diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 11050d7..c59f552 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -243,6 +243,13 @@ describe('configSchema — browser', () => { expect(result.browser.headless).toBe(true); expect(result.browser.max_pages).toBe(5); expect(result.browser.default_timeout).toBe(30000); + expect(result.browser.allowed_domains).toEqual([]); + expect(result.browser.high_risk_domains).toEqual([]); + expect(result.browser.require_confirmation_for_high_risk).toBe(true); + expect(result.browser.max_workflow_steps).toBe(120); + expect(result.browser.default_retry_attempts).toBe(1); + expect(result.browser.max_retry_attempts).toBe(5); + expect(result.browser.retry_delay_ms).toBe(250); }); it('accepts explicit browser config', () => { @@ -254,6 +261,13 @@ describe('configSchema — browser', () => { headless: false, max_pages: 3, default_timeout: 45000, + allowed_domains: ['example.com', '*.trusted.test'], + high_risk_domains: ['bank.example.com'], + require_confirmation_for_high_risk: true, + max_workflow_steps: 40, + default_retry_attempts: 2, + max_retry_attempts: 6, + retry_delay_ms: 500, }, }); @@ -262,6 +276,13 @@ describe('configSchema — browser', () => { expect(result.browser.headless).toBe(false); expect(result.browser.max_pages).toBe(3); expect(result.browser.default_timeout).toBe(45000); + expect(result.browser.allowed_domains).toEqual(['example.com', '*.trusted.test']); + expect(result.browser.high_risk_domains).toEqual(['bank.example.com']); + expect(result.browser.require_confirmation_for_high_risk).toBe(true); + expect(result.browser.max_workflow_steps).toBe(40); + expect(result.browser.default_retry_attempts).toBe(2); + expect(result.browser.max_retry_attempts).toBe(6); + expect(result.browser.retry_delay_ms).toBe(500); }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 4552d66..5b29f37 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -781,6 +781,13 @@ const browserSchema = z.object({ headless: z.boolean().default(true), max_pages: z.number().min(1).max(20).default(5), default_timeout: z.number().min(1000).max(120000).default(30000), + allowed_domains: z.array(z.string().min(1)).default([]), + high_risk_domains: z.array(z.string().min(1)).default([]), + require_confirmation_for_high_risk: z.boolean().default(true), + max_workflow_steps: z.number().int().min(1).max(1000).default(120), + default_retry_attempts: z.number().int().min(1).max(10).default(1), + max_retry_attempts: z.number().int().min(1).max(20).default(5), + retry_delay_ms: z.number().int().min(0).max(10000).default(250), }).default({}); const processSchema = z.object({ diff --git a/src/daemon/tools.ts b/src/daemon/tools.ts index 969f176..571a337 100644 --- a/src/daemon/tools.ts +++ b/src/daemon/tools.ts @@ -67,7 +67,20 @@ export function initTools(deps: ToolsDeps): ToolsResult { } // Initialize browser manager and register browser tools (if enabled) - const browserToolNames = ['browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate']; + const browserToolNames = [ + 'browser.navigate', + 'browser.screenshot', + 'browser.click', + 'browser.type', + 'browser.content', + 'browser.wait_for', + 'browser.assert', + 'browser.extract', + 'browser.checkpoint.save', + 'browser.checkpoint.resume', + 'browser.eval', + 'browser.evaluate', + ]; let browserManager: BrowserManager | undefined; if (config.browser?.enabled) { const manager = new BrowserManager({ @@ -79,7 +92,15 @@ export function initTools(deps: ToolsDeps): ToolsResult { }); browserManager = manager; - for (const tool of createBrowserTools(manager)) { + for (const tool of createBrowserTools(manager, { + allowedDomains: config.browser.allowed_domains, + highRiskDomains: config.browser.high_risk_domains, + requireHighRiskConfirmation: config.browser.require_confirmation_for_high_risk, + maxWorkflowSteps: config.browser.max_workflow_steps, + defaultRetryAttempts: config.browser.default_retry_attempts, + maxRetryAttempts: config.browser.max_retry_attempts, + retryDelayMs: config.browser.retry_delay_ms, + })) { toolRegistry.register(tool); } console.log(`Browser tools enabled (headless=${config.browser.headless})`); diff --git a/src/tools/builtin/browser/tools.test.ts b/src/tools/builtin/browser/tools.test.ts index 54404b2..d8d61c9 100644 --- a/src/tools/builtin/browser/tools.test.ts +++ b/src/tools/builtin/browser/tools.test.ts @@ -8,7 +8,10 @@ const mockUrl = vi.fn().mockReturnValue('https://example.com'); const mockClick = vi.fn().mockResolvedValue(undefined); const mockType = vi.fn().mockResolvedValue(undefined); const mock$eval = vi.fn().mockResolvedValue('Page content here'); +const mock$$eval = vi.fn().mockResolvedValue(['Row 1', 'Row 2']); const mockEvaluate = vi.fn().mockResolvedValue({ result: 42 }); +const mockWaitForSelector = vi.fn().mockResolvedValue(undefined); +const mockWaitForFunction = vi.fn().mockResolvedValue(undefined); const mockScreenshot = vi.fn().mockResolvedValue('base64data'); const mock$ = vi.fn().mockResolvedValue({ screenshot: vi.fn().mockResolvedValue('element-base64') }); const mockKeyboard = { press: vi.fn().mockResolvedValue(undefined) }; @@ -20,7 +23,10 @@ const mockPage = { click: mockClick, type: mockType, $eval: mock$eval, + $$eval: mock$$eval, evaluate: mockEvaluate, + waitForSelector: mockWaitForSelector, + waitForFunction: mockWaitForFunction, screenshot: mockScreenshot, $: mock$, keyboard: mockKeyboard, @@ -44,6 +50,7 @@ describe('Browser tools', () => { beforeEach(() => { vi.clearAllMocks(); + mockUrl.mockReturnValue('https://example.com'); tools = createBrowserTools(mockManager); }); @@ -54,9 +61,14 @@ describe('Browser tools', () => { expect(names).toContain('browser.click'); expect(names).toContain('browser.type'); expect(names).toContain('browser.content'); + expect(names).toContain('browser.wait_for'); + expect(names).toContain('browser.assert'); + expect(names).toContain('browser.extract'); + expect(names).toContain('browser.checkpoint.save'); + expect(names).toContain('browser.checkpoint.resume'); expect(names).toContain('browser.eval'); expect(names).toContain('browser.evaluate'); - expect(names).toHaveLength(7); + expect(names).toHaveLength(12); }); it('browser.navigate navigates to URL', async () => { @@ -73,6 +85,17 @@ describe('Browser tools', () => { expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'networkidle0' }); }); + it('browser.navigate retries on transient errors', async () => { + mockGoto.mockRejectedValueOnce(new Error('temporary down')); + const tool = getTool('browser.navigate'); + const result = await tool.execute({ + url: 'https://example.com', + retry: { attempts: 2, delay_ms: 0 }, + }); + expect(result.success).toBe(true); + expect(mockGoto).toHaveBeenCalledTimes(2); + }); + it('browser.screenshot takes page screenshot', async () => { const tool = getTool('browser.screenshot'); const result = await tool.execute({}); @@ -103,6 +126,17 @@ describe('Browser tools', () => { expect(mockClick).toHaveBeenCalledWith('#submit'); }); + it('browser.click retries on transient failures', async () => { + mockClick.mockRejectedValueOnce(new Error('click miss')); + const tool = getTool('browser.click'); + const result = await tool.execute({ + selector: '#submit', + retry: { attempts: 2, delay_ms: 0 }, + }); + expect(result.success).toBe(true); + expect(mockClick).toHaveBeenCalledTimes(2); + }); + it('browser.type types into element', async () => { const tool = getTool('browser.type'); const result = await tool.execute({ selector: '#search', text: 'hello' }); @@ -133,6 +167,75 @@ describe('Browser tools', () => { expect(mock$eval).toHaveBeenCalledWith('#main', expect.any(Function)); }); + it('browser.wait_for waits on selector and text', async () => { + const tool = getTool('browser.wait_for'); + const result = await tool.execute({ + selector: '#loaded', + text: 'Ready', + timeout_ms: 5000, + }); + expect(result.success).toBe(true); + expect(mockWaitForSelector).toHaveBeenCalledWith('#loaded', { timeout: 5000, visible: false }); + expect(mockWaitForFunction).toHaveBeenCalled(); + }); + + it('browser.assert validates selector/text/url conditions', async () => { + const tool = getTool('browser.assert'); + const result = await tool.execute({ + selector: '#main', + exists: true, + text: 'Page content here', + url_includes: 'example.com', + }); + expect(result.success).toBe(true); + }); + + it('browser.assert fails when conditions are not met', async () => { + mockUrl.mockReturnValue('https://example.com/path'); + mock$eval.mockResolvedValueOnce('different content'); + mock$.mockResolvedValueOnce(null); + const tool = getTool('browser.assert'); + const result = await tool.execute({ + selector: '#missing', + exists: true, + text: 'not present', + url_includes: 'nope', + retry: { attempts: 1 }, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('failed after retries'); + }); + + it('browser.extract returns a single value by selector', async () => { + mock$eval.mockResolvedValueOnce('Primary value'); + const tool = getTool('browser.extract'); + const result = await tool.execute({ selector: '#value' }); + expect(result.success).toBe(true); + expect(result.output).toContain('"value": "Primary value"'); + }); + + it('browser.extract returns array values when all=true', async () => { + mock$$eval.mockResolvedValueOnce(['A', 'B', 'C']); + const tool = getTool('browser.extract'); + const result = await tool.execute({ selector: '.row', all: true }); + expect(result.success).toBe(true); + expect(result.output).toContain('"count": 3'); + expect(result.output).toContain('"values"'); + }); + + it('browser.checkpoint.save and resume navigates to saved url', async () => { + const saveTool = getTool('browser.checkpoint.save'); + const resumeTool = getTool('browser.checkpoint.resume'); + + const saved = await saveTool.execute({ checkpoint_id: 'cp-1' }); + expect(saved.success).toBe(true); + expect(saved.output).toContain('cp-1'); + + const resumed = await resumeTool.execute({ checkpoint_id: 'cp-1' }); + expect(resumed.success).toBe(true); + expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'domcontentloaded' }); + }); + it('browser.eval evaluates JS', async () => { const tool = getTool('browser.eval'); const result = await tool.execute({ expression: '1 + 1' }); @@ -155,10 +258,61 @@ describe('Browser tools', () => { expect(result.output).toContain('42'); }); + it('enforces allowed domain guardrail for navigation', async () => { + const restrictedTools = createBrowserTools(mockManager, { + allowedDomains: ['example.com'], + }); + const navigate = restrictedTools.find((tool) => tool.name === 'browser.navigate'); + if (!navigate) { + throw new Error('missing navigate tool'); + } + const result = await navigate.execute({ url: 'https://blocked.test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('allowed_domains'); + }); + + it('requires explicit high-risk confirmation for configured domains', async () => { + const guardedTools = createBrowserTools(mockManager, { + highRiskDomains: ['bank.example.com'], + requireHighRiskConfirmation: true, + }); + const navigate = guardedTools.find((tool) => tool.name === 'browser.navigate'); + if (!navigate) { + throw new Error('missing navigate tool'); + } + + const denied = await navigate.execute({ url: 'https://bank.example.com' }); + expect(denied.success).toBe(false); + expect(denied.error).toContain('confirm_high_risk=true'); + + const allowed = await navigate.execute({ + url: 'https://bank.example.com', + confirm_high_risk: true, + }); + expect(allowed.success).toBe(true); + }); + + it('enforces workflow step budget', async () => { + const budgetedTools = createBrowserTools(mockManager, { + maxWorkflowSteps: 1, + }); + const navigate = budgetedTools.find((tool) => tool.name === 'browser.navigate'); + const click = budgetedTools.find((tool) => tool.name === 'browser.click'); + if (!navigate || !click) { + throw new Error('missing browser tools'); + } + + const first = await navigate.execute({ url: 'https://example.com' }); + expect(first.success).toBe(true); + const second = await click.execute({ selector: '#submit' }); + expect(second.success).toBe(false); + expect(second.error).toContain('budget exhausted'); + }); + it('handles navigation errors gracefully', async () => { mockGoto.mockRejectedValueOnce(new Error('Navigation failed')); const tool = getTool('browser.navigate'); - const result = await tool.execute({ url: 'https://broken.example.com' }); + const result = await tool.execute({ url: 'https://broken.example.com', retry: { attempts: 1 } }); expect(result.success).toBe(false); expect(result.error).toContain('Navigation failed'); }); @@ -166,7 +320,7 @@ describe('Browser tools', () => { it('handles click errors gracefully', async () => { mockClick.mockRejectedValueOnce(new Error('Element not found')); const tool = getTool('browser.click'); - const result = await tool.execute({ selector: '#missing' }); + const result = await tool.execute({ selector: '#missing', retry: { attempts: 1 } }); expect(result.success).toBe(false); expect(result.error).toContain('Element not found'); }); diff --git a/src/tools/builtin/browser/tools.ts b/src/tools/builtin/browser/tools.ts index abc4a8b..9eb722e 100644 --- a/src/tools/builtin/browser/tools.ts +++ b/src/tools/builtin/browser/tools.ts @@ -1,6 +1,35 @@ import type { Tool, ToolExecutionContext, ToolResult } from '../../types.js'; import type { BrowserManager } from './manager.js'; +interface BrowserRetryConfig { + attempts?: number; + delay_ms?: number; +} + +interface BrowserToolsOptions { + allowedDomains?: string[]; + highRiskDomains?: string[]; + requireHighRiskConfirmation?: boolean; + maxWorkflowSteps?: number; + defaultRetryAttempts?: number; + maxRetryAttempts?: number; + retryDelayMs?: number; +} + +interface BrowserCheckpoint { + id: string; + url: string; + title: string; + createdAt: number; + stepsUsed: number; +} + +interface RetryOptions { + defaultRetryAttempts: number; + maxRetryAttempts: number; + retryDelayMs: number; +} + function abortError(): string { return 'Operation aborted'; } @@ -55,295 +84,946 @@ async function withAbort( }); } +function normalizeDomainPattern(rawPattern: string): string { + const trimmed = rawPattern.trim().toLowerCase(); + if (!trimmed) { + return ''; + } + if (trimmed === '*') { + return '*'; + } + + let candidate = trimmed; + if (candidate.includes('://')) { + try { + candidate = new URL(candidate).hostname.toLowerCase(); + } catch { + return ''; + } + } else { + candidate = candidate.split('/')[0] ?? ''; + } + + if (candidate.startsWith('.')) { + candidate = candidate.slice(1); + } + return candidate; +} + +function domainMatches(hostname: string, pattern: string): boolean { + if (pattern === '*') { + return true; + } + if (pattern.startsWith('*.')) { + const base = pattern.slice(2); + return hostname === base || hostname.endsWith(`.${base}`); + } + return hostname === pattern; +} + +function extractHostname(rawUrl: string): string { + try { + return new URL(rawUrl).hostname.toLowerCase(); + } catch { + throw new Error(`Invalid URL: ${rawUrl}`); + } +} + +function normalizeRetryConfig( + retry: BrowserRetryConfig | undefined, + defaults: RetryOptions, +): { attempts: number; delayMs: number } { + const attemptsInput = retry?.attempts ?? defaults.defaultRetryAttempts; + const attemptsRaw = Number.isFinite(attemptsInput) ? Number(attemptsInput) : defaults.defaultRetryAttempts; + const delayInput = retry?.delay_ms ?? defaults.retryDelayMs; + const delayRaw = Number.isFinite(delayInput) ? Number(delayInput) : defaults.retryDelayMs; + const attempts = Math.max(1, Math.min(defaults.maxRetryAttempts, Math.floor(attemptsRaw))); + const delayMs = Math.max(0, Math.floor(delayRaw)); + return { attempts, delayMs }; +} + +async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) { + return; + } + await withAbort(new Promise((resolve) => { + const timer = setTimeout(() => { + clearTimeout(timer); + resolve(); + }, ms); + }), signal); +} + +async function runWithRetry( + label: string, + operation: (attempt: number) => Promise, + retry: BrowserRetryConfig | undefined, + defaults: RetryOptions, + signal?: AbortSignal, +): Promise { + const { attempts, delayMs } = normalizeRetryConfig(retry, defaults); + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + throwIfAborted(signal); + try { + return await operation(attempt); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + lastError = error; + if (attempt >= attempts) { + break; + } + await sleepWithAbort(delayMs, signal); + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`${label} failed after retries: ${message}`); +} + /** Create all browser tools bound to a BrowserManager instance. */ -export function createBrowserTools(manager: BrowserManager): Tool[] { +export function createBrowserTools(manager: BrowserManager, options: BrowserToolsOptions = {}): Tool[] { + const allowedDomains = (options.allowedDomains ?? []).map(normalizeDomainPattern).filter(Boolean); + const highRiskDomains = (options.highRiskDomains ?? []).map(normalizeDomainPattern).filter(Boolean); + const requireHighRiskConfirmation = options.requireHighRiskConfirmation ?? true; + const maxWorkflowSteps = options.maxWorkflowSteps; + const retryOptions: RetryOptions = { + defaultRetryAttempts: options.defaultRetryAttempts ?? 1, + maxRetryAttempts: options.maxRetryAttempts ?? 5, + retryDelayMs: options.retryDelayMs ?? 250, + }; + let stepsUsed = 0; + const checkpoints = new Map(); + let latestCheckpointId: string | undefined; + + function consumeWorkflowStep(action: string): void { + if (typeof maxWorkflowSteps !== 'number') { + return; + } + if (stepsUsed >= maxWorkflowSteps) { + throw new Error(`Browser workflow step budget exhausted (${stepsUsed}/${maxWorkflowSteps}) before ${action}`); + } + stepsUsed += 1; + } + + function ensureDomainAllowed(rawUrl: string): void { + if (allowedDomains.length === 0) { + return; + } + const hostname = extractHostname(rawUrl); + if (!allowedDomains.some((pattern) => domainMatches(hostname, pattern))) { + throw new Error(`Navigation to '${hostname}' denied by browser.allowed_domains`); + } + } + + function ensureHighRiskConfirmed(rawUrl: string, confirmHighRisk?: boolean): void { + if (!requireHighRiskConfirmation || highRiskDomains.length === 0) { + return; + } + const hostname = extractHostname(rawUrl); + const isHighRisk = highRiskDomains.some((pattern) => domainMatches(hostname, pattern)); + if (isHighRisk && !confirmHighRisk) { + throw new Error( + `Domain '${hostname}' is marked high-risk. Re-run with confirm_high_risk=true after explicit user confirmation.`, + ); + } + } + + function ensureCurrentPageDomainAllowed(rawUrl: string): void { + if (allowedDomains.length === 0) { + return; + } + const hostname = extractHostname(rawUrl); + if (!allowedDomains.some((pattern) => domainMatches(hostname, pattern))) { + throw new Error(`Current page '${hostname}' is outside browser.allowed_domains`); + } + } + return [ createBrowserNavigateTool(manager), createBrowserScreenshotTool(manager), createBrowserClickTool(manager), createBrowserTypeTool(manager), createBrowserContentTool(manager), + createBrowserWaitForTool(manager), + createBrowserAssertTool(manager), + createBrowserExtractTool(manager), + createBrowserCheckpointSaveTool(manager), + createBrowserCheckpointResumeTool(manager), createBrowserEvalTool(manager), createBrowserEvaluateTool(manager), ]; -} -function createBrowserNavigateTool(manager: BrowserManager): Tool { - return { - name: 'browser.navigate', - description: 'Navigate to a URL in the browser. Returns the page title and URL after navigation.', - inputSchema: { - type: 'object', - properties: { - url: { type: 'string', description: 'The URL to navigate to' }, - waitUntil: { - type: 'string', - description: 'When to consider navigation complete: load, domcontentloaded, networkidle0, networkidle2 (default: domcontentloaded)', + function createBrowserNavigateTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.navigate', + description: 'Navigate to a URL in the browser. Returns the page title and URL after navigation.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'The URL to navigate to' }, + waitUntil: { + type: 'string', + description: 'When to consider navigation complete: load, domcontentloaded, networkidle0, networkidle2 (default: domcontentloaded)', + }, + confirm_high_risk: { + type: 'boolean', + description: 'Required when navigating to a configured browser.high_risk_domains hostname.', + }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + required: ['url'], + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { url: string; waitUntil?: string; confirm_high_risk?: boolean; retry?: BrowserRetryConfig }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureDomainAllowed(args.url); + ensureHighRiskConfirmed(args.url, args.confirm_high_risk); + const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; + + await runWithRetry( + 'browser.navigate', + async () => { + consumeWorkflowStep('browser.navigate'); + await withAbort( + page.goto(args.url, { waitUntil }), + context?.signal, + () => { + void page.evaluate(() => { + (globalThis as { stop?: () => void }).stop?.(); + }).catch(() => undefined); + }, + ); + }, + args.retry, + retryOptions, + context?.signal, + ); + + throwIfAborted(context?.signal); + const title = await page.title(); + const currentUrl = page.url(); + return { + success: true, + output: `Navigated to: ${currentUrl}\nTitle: ${title}\nWorkflow steps used: ${stepsUsed}`, + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserScreenshotTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.screenshot', + description: 'Take a screenshot of the current page. Returns the screenshot as a base64-encoded PNG.', + inputSchema: { + type: 'object', + properties: { + fullPage: { type: 'boolean', description: 'Capture full scrollable page (default: false)' }, + selector: { type: 'string', description: 'CSS selector to screenshot a specific element' }, }, }, - required: ['url'], - }, - execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { - const args = rawArgs as { url: string; waitUntil?: string }; - try { - throwIfAborted(context?.signal); - const page = await manager.getPage(); - throwIfAborted(context?.signal); - const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; - await withAbort( - page.goto(args.url, { waitUntil }), - context?.signal, - () => { - void page.evaluate(() => { - (globalThis as { stop?: () => void }).stop?.(); - }).catch(() => undefined); - }, - ); - throwIfAborted(context?.signal); - const title = await page.title(); - const currentUrl = page.url(); - return { - success: true, - output: `Navigated to: ${currentUrl}\nTitle: ${title}`, - }; - } catch (error) { - if (isAbortError(error)) { - return { success: false, output: '', error: abortError() }; - } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), - }; - } - }, - }; -} + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { fullPage?: boolean; selector?: string }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); -function createBrowserScreenshotTool(manager: BrowserManager): Tool { - return { - name: 'browser.screenshot', - description: 'Take a screenshot of the current page. Returns the screenshot as a base64-encoded PNG.', - inputSchema: { - type: 'object', - properties: { - fullPage: { type: 'boolean', description: 'Capture full scrollable page (default: false)' }, - selector: { type: 'string', description: 'CSS selector to screenshot a specific element' }, - }, - }, - execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { - const args = rawArgs as { fullPage?: boolean; selector?: string }; - try { - throwIfAborted(context?.signal); - const page = await manager.getPage(); - throwIfAborted(context?.signal); - - let screenshotData: string; - if (args.selector) { - const element = await page.$(args.selector); - if (!element) { - return { success: false, output: '', error: `Element not found: ${args.selector}` }; + consumeWorkflowStep('browser.screenshot'); + let screenshotData: string; + if (args.selector) { + const element = await page.$(args.selector); + if (!element) { + return { success: false, output: '', error: `Element not found: ${args.selector}` }; + } + screenshotData = (await element.screenshot({ encoding: 'base64' })) as string; + } else { + screenshotData = (await page.screenshot({ + encoding: 'base64', + fullPage: args.fullPage ?? false, + })) as string; } - screenshotData = (await element.screenshot({ encoding: 'base64' })) as string; - } else { - screenshotData = (await page.screenshot({ - encoding: 'base64', - fullPage: args.fullPage ?? false, - })) as string; - } - return { - success: true, - output: `Screenshot captured (base64 PNG, ${screenshotData.length} chars):\n${screenshotData.slice(0, 200)}...`, - }; - } catch (error) { - if (isAbortError(error)) { - return { success: false, output: '', error: abortError() }; + return { + success: true, + output: `Screenshot captured (base64 PNG, ${screenshotData.length} chars):\n${screenshotData.slice(0, 200)}...`, + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), - }; - } - }, - }; -} - -function createBrowserClickTool(manager: BrowserManager): Tool { - return { - name: 'browser.click', - description: 'Click an element on the page identified by CSS selector.', - inputSchema: { - type: 'object', - properties: { - selector: { type: 'string', description: 'CSS selector of the element to click' }, }, - required: ['selector'], - }, - execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { - const args = rawArgs as { selector: string }; - try { - throwIfAborted(context?.signal); - const page = await manager.getPage(); - throwIfAborted(context?.signal); - await withAbort(page.click(args.selector), context?.signal); - return { success: true, output: `Clicked element: ${args.selector}` }; - } catch (error) { - if (isAbortError(error)) { - return { success: false, output: '', error: abortError() }; - } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), - }; - } - }, - }; -} + }; + } -function createBrowserTypeTool(manager: BrowserManager): Tool { - return { - name: 'browser.type', - description: 'Type text into an input element on the page.', - inputSchema: { - type: 'object', - properties: { - selector: { type: 'string', description: 'CSS selector of the input element' }, - text: { type: 'string', description: 'Text to type' }, - clear: { type: 'boolean', description: 'Clear the field before typing (default: false)' }, + function createBrowserClickTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.click', + description: 'Click an element on the page identified by CSS selector.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string', description: 'CSS selector of the element to click' }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + required: ['selector'], }, - required: ['selector', 'text'], - }, - execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { - const args = rawArgs as { selector: string; text: string; clear?: boolean }; - try { - throwIfAborted(context?.signal); - const page = await manager.getPage(); - throwIfAborted(context?.signal); - if (args.clear) { - await withAbort(page.click(args.selector, { count: 3 }), context?.signal); // Select all - await withAbort(page.keyboard.press('Backspace'), context?.signal); + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { selector: string; retry?: BrowserRetryConfig }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + await runWithRetry( + 'browser.click', + async () => { + consumeWorkflowStep('browser.click'); + await withAbort(page.click(args.selector), context?.signal); + }, + args.retry, + retryOptions, + context?.signal, + ); + return { success: true, output: `Clicked element: ${args.selector}` }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; } - await withAbort(page.type(args.selector, args.text), context?.signal); - return { success: true, output: `Typed "${args.text}" into ${args.selector}` }; - } catch (error) { - if (isAbortError(error)) { - return { success: false, output: '', error: abortError() }; - } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), - }; - } - }, - }; -} - -function createBrowserContentTool(manager: BrowserManager): Tool { - return { - name: 'browser.content', - description: 'Get the text content of the page or a specific element. Returns extracted text (not raw HTML).', - inputSchema: { - type: 'object', - properties: { - selector: { type: 'string', description: 'CSS selector to get content of a specific element (default: body)' }, - maxLength: { type: 'number', description: 'Maximum characters to return (default: 10000)' }, }, - }, - execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { - const args = rawArgs as { selector?: string; maxLength?: number }; - try { - throwIfAborted(context?.signal); - const page = await manager.getPage(); - throwIfAborted(context?.signal); - const selector = args.selector ?? 'body'; - const maxLength = args.maxLength ?? 10000; + }; + } - // The $eval callback executes in the browser context where DOM types exist, - // but TS checks against Node.js lib (no DOM). Use `any` to bridge the gap. - const text = await withAbort(page.$eval(selector, (el) => { - const htmlEl = el as unknown as { innerText?: string; textContent?: string | null }; - return htmlEl.innerText || htmlEl.textContent || ''; - }), context?.signal); - - const truncated = text.length > maxLength - ? text.slice(0, maxLength) + `\n... (truncated, ${text.length} total chars)` - : text; - - const title = await page.title(); - const url = page.url(); - - return { - success: true, - output: `URL: ${url}\nTitle: ${title}\n\n${truncated}`, - }; - } catch (error) { - if (isAbortError(error)) { - return { success: false, output: '', error: abortError() }; - } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), - }; - } - }, - }; -} - -function createBrowserEvalTool(manager: BrowserManager): Tool { - return createBrowserEvalLikeTool( - manager, - 'browser.eval', - 'Evaluate JavaScript in the browser page context. Returns the result as a string.', - ); -} - -function createBrowserEvaluateTool(manager: BrowserManager): Tool { - return createBrowserEvalLikeTool( - manager, - 'browser.evaluate', - 'Alias of browser.eval for compatibility. Evaluates JavaScript in the browser page context.', - ); -} - -function createBrowserEvalLikeTool(manager: BrowserManager, name: 'browser.eval' | 'browser.evaluate', description: string): Tool { - return { - name, - description, - inputSchema: { - type: 'object', - properties: { - expression: { type: 'string', description: 'JavaScript expression to evaluate in the page context' }, + function createBrowserTypeTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.type', + description: 'Type text into an input element on the page.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string', description: 'CSS selector of the input element' }, + text: { type: 'string', description: 'Text to type' }, + clear: { type: 'boolean', description: 'Clear the field before typing (default: false)' }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + required: ['selector', 'text'], }, - required: ['expression'], - }, - execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { - const args = rawArgs as { expression: string }; - try { - throwIfAborted(context?.signal); - const page = await manager.getPage(); - throwIfAborted(context?.signal); - // Use evaluate with a function that evaluates the expression string - const result = await withAbort(page.evaluate((expr: string) => { - // eslint-disable-next-line no-eval - return eval(expr); - }, args.expression), context?.signal); - const output = typeof result === 'string' ? result : JSON.stringify(result, null, 2); - return { success: true, output: output ?? 'undefined' }; - } catch (error) { - if (isAbortError(error)) { - return { success: false, output: '', error: abortError() }; + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { selector: string; text: string; clear?: boolean; retry?: BrowserRetryConfig }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + await runWithRetry( + 'browser.type', + async () => { + consumeWorkflowStep('browser.type'); + if (args.clear) { + await withAbort(page.click(args.selector, { count: 3 }), context?.signal); // Select all + await withAbort(page.keyboard.press('Backspace'), context?.signal); + } + await withAbort(page.type(args.selector, args.text), context?.signal); + }, + args.retry, + retryOptions, + context?.signal, + ); + return { success: true, output: `Typed "${args.text}" into ${args.selector}` }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), + }, + }; + } + + function createBrowserContentTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.content', + description: 'Get the text content of the page or a specific element. Returns extracted text (not raw HTML).', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string', description: 'CSS selector to get content of a specific element (default: body)' }, + maxLength: { type: 'number', description: 'Maximum characters to return (default: 10000)' }, + }, + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { selector?: string; maxLength?: number }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + consumeWorkflowStep('browser.content'); + const selector = args.selector ?? 'body'; + const maxLength = args.maxLength ?? 10000; + + // The $eval callback executes in the browser context where DOM types exist, + // but TS checks against Node.js lib (no DOM). Use `any` to bridge the gap. + const text = await withAbort(page.$eval(selector, (el) => { + const htmlEl = el as unknown as { innerText?: string; textContent?: string | null }; + return htmlEl.innerText || htmlEl.textContent || ''; + }), context?.signal); + + const truncated = text.length > maxLength + ? text.slice(0, maxLength) + `\n... (truncated, ${text.length} total chars)` + : text; + + const title = await page.title(); + const url = page.url(); + + return { + success: true, + output: `URL: ${url}\nTitle: ${title}\n\n${truncated}`, + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserWaitForTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.wait_for', + description: 'Wait for selector/text conditions to become true before continuing a workflow step.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string', description: 'CSS selector to wait for' }, + text: { type: 'string', description: 'Text that must appear in the page (or selected element)' }, + visible: { type: 'boolean', description: 'When waiting for selector, require it to be visible (default: false)' }, + timeout_ms: { type: 'number', description: 'Timeout for each wait attempt (default: page timeout)' }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { + selector?: string; + text?: string; + visible?: boolean; + timeout_ms?: number; + retry?: BrowserRetryConfig; }; - } - }, - }; + + try { + if (!args.selector && !args.text) { + return { success: false, output: '', error: 'browser.wait_for requires selector and/or text' }; + } + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + const timeoutMs = args.timeout_ms; + + await runWithRetry( + 'browser.wait_for', + async () => { + consumeWorkflowStep('browser.wait_for'); + if (args.selector) { + await withAbort( + page.waitForSelector(args.selector, { + timeout: timeoutMs, + visible: args.visible ?? false, + }), + context?.signal, + ); + } + if (args.text) { + await withAbort(page.waitForFunction( + (needle: string, selector: string | null) => { + const doc = (globalThis as { + document?: { + querySelector?: (value: string) => unknown; + body?: unknown; + }; + }).document; + const root = selector ? doc?.querySelector?.(selector) : doc?.body; + if (!root) { + return false; + } + const container = root as { innerText?: string; textContent?: string | null }; + const textValue = container.innerText ?? container.textContent ?? ''; + return textValue.includes(needle); + }, + { timeout: timeoutMs }, + args.text, + args.selector ?? null, + ), context?.signal); + } + }, + args.retry, + retryOptions, + context?.signal, + ); + + return { + success: true, + output: `wait_for satisfied${args.selector ? ` selector=${args.selector}` : ''}${args.text ? ` text="${args.text}"` : ''}`, + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserAssertTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.assert', + description: 'Assert page conditions (selector presence, text match, URL fragment) with deterministic failures.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string', description: 'Optional selector for existence/text assertions' }, + exists: { type: 'boolean', description: 'Expected selector existence (default: true when selector provided)' }, + text: { type: 'string', description: 'Text that must be present in page or selector scope' }, + url_includes: { type: 'string', description: 'Substring that must exist in current URL' }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { + selector?: string; + exists?: boolean; + text?: string; + url_includes?: string; + retry?: BrowserRetryConfig; + }; + + try { + const hasAssertion = Boolean(args.selector || args.text || args.url_includes); + if (!hasAssertion) { + return { success: false, output: '', error: 'browser.assert requires at least one assertion field' }; + } + if (typeof args.exists === 'boolean' && !args.selector) { + return { success: false, output: '', error: 'browser.assert exists requires selector' }; + } + + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + + await runWithRetry( + 'browser.assert', + async () => { + consumeWorkflowStep('browser.assert'); + const failures: string[] = []; + + if (args.url_includes && !page.url().includes(args.url_includes)) { + failures.push(`url_includes failed (url=${page.url()}, expected fragment=${args.url_includes})`); + } + + if (args.selector) { + const exists = Boolean(await page.$(args.selector)); + const expected = args.exists ?? true; + if (exists !== expected) { + failures.push(`selector existence failed (${args.selector}, expected=${expected}, actual=${exists})`); + } + } + + if (args.text) { + const scopeSelector = args.selector ?? 'body'; + const textValue = await page.$eval(scopeSelector, (el) => { + const htmlEl = el as unknown as { innerText?: string; textContent?: string | null }; + return htmlEl.innerText || htmlEl.textContent || ''; + }); + if (!textValue.includes(args.text)) { + failures.push(`text assertion failed (missing "${args.text}" in ${scopeSelector})`); + } + } + + if (failures.length > 0) { + throw new Error(failures.join('; ')); + } + }, + args.retry, + retryOptions, + context?.signal, + ); + + return { success: true, output: 'Assertions passed' }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserExtractTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.extract', + description: 'Extract structured values from elements by selector and attribute/text.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string', description: 'Selector to extract from' }, + attribute: { type: 'string', description: 'Attribute name to extract (default: text)' }, + all: { type: 'boolean', description: 'Extract from all matched elements (default: false)' }, + max_length: { type: 'number', description: 'Max characters per extracted value (default: 10000)' }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + required: ['selector'], + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { + selector: string; + attribute?: string; + all?: boolean; + max_length?: number; + retry?: BrowserRetryConfig; + }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + const attribute = args.attribute?.trim() || 'text'; + const maxLength = args.max_length ?? 10000; + + const value = await runWithRetry( + 'browser.extract', + async () => { + consumeWorkflowStep('browser.extract'); + if (args.all) { + return page.$$eval( + args.selector, + (elements, attr) => elements.map((el) => { + if (attr === 'text') { + const htmlEl = el as unknown as { innerText?: string; textContent?: string | null }; + return htmlEl.innerText || htmlEl.textContent || ''; + } + return el.getAttribute(attr) ?? ''; + }), + attribute, + ) as Promise; + } + return page.$eval( + args.selector, + (el, attr) => { + if (attr === 'text') { + const htmlEl = el as unknown as { innerText?: string; textContent?: string | null }; + return htmlEl.innerText || htmlEl.textContent || ''; + } + return el.getAttribute(attr) ?? ''; + }, + attribute, + ) as Promise; + }, + args.retry, + retryOptions, + context?.signal, + ); + + if (Array.isArray(value)) { + const truncated = value.map((item) => item.length > maxLength ? `${item.slice(0, maxLength)}…` : item); + return { + success: true, + output: JSON.stringify({ + selector: args.selector, + attribute, + count: truncated.length, + values: truncated, + }, null, 2), + }; + } + + const truncated = value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; + return { + success: true, + output: JSON.stringify({ + selector: args.selector, + attribute, + value: truncated, + }, null, 2), + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserCheckpointSaveTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.checkpoint.save', + description: 'Save the current page URL/title as a named browser workflow checkpoint.', + inputSchema: { + type: 'object', + properties: { + checkpoint_id: { type: 'string', description: 'Optional explicit checkpoint id' }, + }, + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { checkpoint_id?: string }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + consumeWorkflowStep('browser.checkpoint.save'); + const id = args.checkpoint_id?.trim() || `cp-${Date.now()}`; + const checkpoint: BrowserCheckpoint = { + id, + url: page.url(), + title: await page.title(), + createdAt: Date.now(), + stepsUsed, + }; + checkpoints.set(id, checkpoint); + latestCheckpointId = id; + return { + success: true, + output: `Saved checkpoint '${id}' at ${checkpoint.url} (steps=${checkpoint.stepsUsed})`, + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserCheckpointResumeTool(browserManager: BrowserManager): Tool { + return { + name: 'browser.checkpoint.resume', + description: 'Resume a saved browser workflow checkpoint by navigating back to its stored URL.', + inputSchema: { + type: 'object', + properties: { + checkpoint_id: { type: 'string', description: 'Checkpoint id to resume. Defaults to the latest saved checkpoint.' }, + waitUntil: { + type: 'string', + description: 'When to consider navigation complete: load, domcontentloaded, networkidle0, networkidle2 (default: domcontentloaded)', + }, + confirm_high_risk: { + type: 'boolean', + description: 'Required when resuming a high-risk domain checkpoint.', + }, + retry: { + type: 'object', + properties: { + attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' }, + delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' }, + }, + }, + }, + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { + checkpoint_id?: string; + waitUntil?: string; + confirm_high_risk?: boolean; + retry?: BrowserRetryConfig; + }; + try { + throwIfAborted(context?.signal); + const checkpointId = args.checkpoint_id?.trim() || latestCheckpointId; + if (!checkpointId) { + return { success: false, output: '', error: 'No checkpoint available to resume' }; + } + const checkpoint = checkpoints.get(checkpointId); + if (!checkpoint) { + return { success: false, output: '', error: `Unknown checkpoint: ${checkpointId}` }; + } + + ensureDomainAllowed(checkpoint.url); + ensureHighRiskConfirmed(checkpoint.url, args.confirm_high_risk); + + const page = await browserManager.getPage(); + const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; + await runWithRetry( + 'browser.checkpoint.resume', + async () => { + consumeWorkflowStep('browser.checkpoint.resume'); + await withAbort(page.goto(checkpoint.url, { waitUntil }), context?.signal); + }, + args.retry, + retryOptions, + context?.signal, + ); + + const currentUrl = page.url(); + const title = await page.title(); + return { + success: true, + output: `Resumed checkpoint '${checkpointId}': ${currentUrl}\nTitle: ${title}`, + }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } + + function createBrowserEvalTool(browserManager: BrowserManager): Tool { + return createBrowserEvalLikeTool( + browserManager, + 'browser.eval', + 'Evaluate JavaScript in the browser page context. Returns the result as a string.', + ); + } + + function createBrowserEvaluateTool(browserManager: BrowserManager): Tool { + return createBrowserEvalLikeTool( + browserManager, + 'browser.evaluate', + 'Alias of browser.eval for compatibility. Evaluates JavaScript in the browser page context.', + ); + } + + function createBrowserEvalLikeTool(browserManager: BrowserManager, name: 'browser.eval' | 'browser.evaluate', description: string): Tool { + return { + name, + description, + inputSchema: { + type: 'object', + properties: { + expression: { type: 'string', description: 'JavaScript expression to evaluate in the page context' }, + }, + required: ['expression'], + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as { expression: string }; + try { + throwIfAborted(context?.signal); + const page = await browserManager.getPage(); + throwIfAborted(context?.signal); + ensureCurrentPageDomainAllowed(page.url()); + consumeWorkflowStep(name); + // Use evaluate with a function that evaluates the expression string + const result = await withAbort(page.evaluate((expr: string) => { + // eslint-disable-next-line no-eval + return eval(expr); + }, args.expression), context?.signal); + const output = typeof result === 'string' ? result : JSON.stringify(result, null, 2); + return { success: true, output: output ?? 'undefined' }; + } catch (error) { + if (isAbortError(error)) { + return { success: false, output: '', error: abortError() }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + } } diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 5c59ead..53f24a2 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -108,6 +108,11 @@ const PROFILE_TOOLS: Record> = { 'browser.click', 'browser.type', 'browser.content', + 'browser.wait_for', + 'browser.assert', + 'browser.extract', + 'browser.checkpoint.save', + 'browser.checkpoint.resume', 'browser.eval', 'browser.evaluate', 'agent.delegate', @@ -129,7 +134,23 @@ const PROFILE_TOOLS: Record> = { export const TOOL_GROUPS: Record = { 'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'], 'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'], - 'group:web': ['web.fetch', 'web.search', 'web.search.news', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'], + 'group:web': [ + 'web.fetch', + 'web.search', + 'web.search.news', + 'browser.navigate', + 'browser.screenshot', + 'browser.click', + 'browser.type', + 'browser.content', + 'browser.wait_for', + 'browser.assert', + 'browser.extract', + 'browser.checkpoint.save', + 'browser.checkpoint.resume', + 'browser.eval', + 'browser.evaluate', + ], 'group:memory': ['memory.read', 'memory.write', 'memory.search'], 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read', 'gmail.filter.create'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],