intercepts pointer events');
- const result = toAIFriendlyError(error, '@e1');
-
- expect(result.message).toContain('cookie banners');
- });
- });
-});
diff --git a/src/actions.ts b/src/actions.ts
deleted file mode 100644
index e1b1c3e..0000000
--- a/src/actions.ts
+++ /dev/null
@@ -1,2930 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-import { exec } from 'node:child_process';
-import type { Page, Frame } from 'playwright-core';
-import { mkdirSync } from 'node:fs';
-import type { BrowserManager, ScreencastFrame } from './browser.js';
-import { getAppDir } from './daemon.js';
-import {
- type ActionPolicy,
- checkPolicy,
- describeAction,
- getActionCategory,
- loadPolicyFile,
- initPolicyReloader,
- reloadPolicyIfChanged,
-} from './action-policy.js';
-import { requestConfirmation, getAndRemovePending } from './confirmation.js';
-import { getAuthProfile, updateLastLogin } from './auth-vault.js';
-import {
- getSessionsDir,
- readStateFile,
- isValidSessionName,
- isEncryptedPayload,
- listStateFiles,
- cleanupExpiredStates,
-} from './state-utils.js';
-import type {
- Command,
- Response,
- NavigateCommand,
- ClickCommand,
- TypeCommand,
- FillCommand,
- CheckCommand,
- UncheckCommand,
- UploadCommand,
- DoubleClickCommand,
- FocusCommand,
- DragCommand,
- FrameCommand,
- GetByRoleCommand,
- GetByTextCommand,
- GetByLabelCommand,
- GetByPlaceholderCommand,
- PressCommand,
- ScreenshotCommand,
- EvaluateCommand,
- WaitCommand,
- ScrollCommand,
- SelectCommand,
- HoverCommand,
- ContentCommand,
- TabNewCommand,
- TabSwitchCommand,
- TabCloseCommand,
- WindowNewCommand,
- CookiesSetCommand,
- StorageGetCommand,
- StorageSetCommand,
- StorageClearCommand,
- DialogCommand,
- PdfCommand,
- RouteCommand,
- RequestsCommand,
- DownloadCommand,
- GeolocationCommand,
- PermissionsCommand,
- ViewportCommand,
- DeviceCommand,
- GetAttributeCommand,
- GetTextCommand,
- IsVisibleCommand,
- IsEnabledCommand,
- IsCheckedCommand,
- CountCommand,
- BoundingBoxCommand,
- StylesCommand,
- TraceStartCommand,
- TraceStopCommand,
- ProfilerStartCommand,
- ProfilerStopCommand,
- HarStopCommand,
- StorageStateSaveCommand,
- StateListCommand,
- StateClearCommand,
- StateShowCommand,
- StateCleanCommand,
- StateRenameCommand,
- ConsoleCommand,
- ErrorsCommand,
- KeyboardCommand,
- WheelCommand,
- TapCommand,
- ClipboardCommand,
- HighlightCommand,
- ClearCommand,
- SelectAllCommand,
- InnerTextCommand,
- InnerHtmlCommand,
- InputValueCommand,
- SetValueCommand,
- DispatchEventCommand,
- AddScriptCommand,
- AddStyleCommand,
- EmulateMediaCommand,
- OfflineCommand,
- HeadersCommand,
- GetByAltTextCommand,
- GetByTitleCommand,
- GetByTestIdCommand,
- NthCommand,
- WaitForUrlCommand,
- WaitForLoadStateCommand,
- SetContentCommand,
- TimezoneCommand,
- LocaleCommand,
- HttpCredentialsCommand,
- MouseMoveCommand,
- MouseDownCommand,
- MouseUpCommand,
- WaitForFunctionCommand,
- ScrollIntoViewCommand,
- AddInitScriptCommand,
- KeyDownCommand,
- KeyUpCommand,
- InsertTextCommand,
- MultiSelectCommand,
- WaitForDownloadCommand,
- ResponseBodyCommand,
- ScreencastStartCommand,
- ScreencastStopCommand,
- InputMouseCommand,
- InputKeyboardCommand,
- InputTouchCommand,
- RecordingStartCommand,
- RecordingStopCommand,
- RecordingRestartCommand,
- DiffSnapshotCommand,
- DiffScreenshotCommand,
- DiffUrlCommand,
- AuthLoginCommand,
- ConfirmCommand,
- DenyCommand,
- Annotation,
- NavigateData,
- ScreenshotData,
- EvaluateData,
- DiffSnapshotData,
- DiffScreenshotData,
- DiffUrlData,
- ContentData,
- TabListData,
- TabNewData,
- TabSwitchData,
- TabCloseData,
- ScreencastStartData,
- ScreencastStopData,
- RecordingStartData,
- RecordingStopData,
- RecordingRestartData,
- InputEventData,
- StylesData,
-} from './types.js';
-import { successResponse, errorResponse, parseCommand } from './protocol.js';
-import { diffSnapshots, diffScreenshots } from './diff.js';
-import { getEnhancedSnapshot } from './snapshot.js';
-
-// Callback for screencast frames - will be set by the daemon when streaming is active
-let screencastFrameCallback: ((frame: ScreencastFrame) => void) | null = null;
-
-/**
- * Set the callback for screencast frames
- * This is called by the daemon to set up frame streaming
- */
-export function setScreencastFrameCallback(
- callback: ((frame: ScreencastFrame) => void) | null
-): void {
- screencastFrameCallback = callback;
-}
-
-// Snapshot response type
-interface SnapshotData {
- snapshot: string;
- refs?: Record
;
-}
-
-/**
- * Convert Playwright errors to AI-friendly messages
- * @internal Exported for testing
- */
-export function toAIFriendlyError(error: unknown, selector: string): Error {
- const message = error instanceof Error ? error.message : String(error);
-
- // Handle strict mode violation (multiple elements match)
- if (message.includes('strict mode violation')) {
- // Extract count if available
- const countMatch = message.match(/resolved to (\d+) elements/);
- const count = countMatch ? countMatch[1] : 'multiple';
-
- return new Error(
- `Selector "${selector}" matched ${count} elements. ` +
- `Run 'snapshot' to get updated refs, or use a more specific CSS selector.`
- );
- }
-
- // Handle element not interactable (must be checked BEFORE timeout case)
- // This includes cases where an overlay/modal blocks the element
- if (message.includes('intercepts pointer events')) {
- return new Error(
- `Element "${selector}" is blocked by another element (likely a modal or overlay). ` +
- `Try dismissing any modals/cookie banners first.`
- );
- }
-
- // Handle element not visible
- if (message.includes('not visible') && !message.includes('Timeout')) {
- return new Error(
- `Element "${selector}" is not visible. ` +
- `Try scrolling it into view or check if it's hidden.`
- );
- }
-
- // Handle general timeout (element exists but action couldn't complete)
- if (message.includes('Timeout') && message.includes('exceeded')) {
- return new Error(
- `Action on "${selector}" timed out. The element may be blocked, still loading, or not interactable. ` +
- `Run 'snapshot' to check the current page state.`
- );
- }
-
- // Handle element not found (timeout waiting for element)
- if (
- message.includes('waiting for') &&
- (message.includes('to be visible') || message.includes('Timeout'))
- ) {
- return new Error(
- `Element "${selector}" not found or not visible. ` +
- `Run 'snapshot' to see current page elements.`
- );
- }
-
- // Return original error for unknown cases
- return error instanceof Error ? error : new Error(message);
-}
-
-let actionPolicy: ActionPolicy | null = null;
-let confirmCategories = new Set();
-
-export function initActionPolicy(): void {
- const policyPath = process.env.AGENT_BROWSER_ACTION_POLICY;
- if (policyPath) {
- try {
- actionPolicy = loadPolicyFile(policyPath);
- initPolicyReloader(policyPath, actionPolicy);
- } catch (err) {
- console.error(
- `[ERROR] Failed to load action policy from ${policyPath}: ${err instanceof Error ? err.message : err}`
- );
- process.exit(1);
- }
- }
-
- const confirmActionsEnv = process.env.AGENT_BROWSER_CONFIRM_ACTIONS;
- if (confirmActionsEnv) {
- confirmCategories = new Set(
- confirmActionsEnv
- .split(',')
- .map((c) => c.trim().toLowerCase())
- .filter((c) => c.length > 0)
- );
- }
-}
-
-/**
- * Execute a command and return a response
- */
-export async function executeCommand(command: Command, browser: BrowserManager): Promise {
- try {
- // Handle confirm/deny actions (bypass policy check)
- if (command.action === 'confirm') {
- return await handleConfirm(command, browser);
- }
- if (command.action === 'deny') {
- return handleDeny(command);
- }
-
- // Hot-reload policy file if it changed on disk
- actionPolicy = reloadPolicyIfChanged();
-
- // Policy enforcement
- const decision = checkPolicy(command.action, actionPolicy, confirmCategories);
- if (decision === 'deny') {
- const category = getActionCategory(command.action);
- return errorResponse(command.id, `Action denied by policy: '${category}' is not allowed`);
- }
- if (decision === 'confirm') {
- const category = getActionCategory(command.action);
- const description = describeAction(
- command.action,
- command as unknown as Record
- );
- const { confirmationId } = requestConfirmation(
- command.action,
- category,
- description,
- command as unknown as Record
- );
- return successResponse(command.id, {
- confirmation_required: true,
- action: command.action,
- category,
- description,
- confirmation_id: confirmationId,
- });
- }
-
- return await dispatchAction(command, browser);
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- return errorResponse(command.id, message);
- }
-}
-
-/**
- * Dispatch a command to its handler after policy checks have passed.
- */
-async function dispatchAction(command: Command, browser: BrowserManager): Promise {
- switch (command.action) {
- case 'launch':
- return await handleLaunch(command, browser);
- case 'navigate':
- return await handleNavigate(command, browser);
- case 'click':
- return await handleClick(command, browser);
- case 'type':
- return await handleType(command, browser);
- case 'fill':
- return await handleFill(command, browser);
- case 'check':
- return await handleCheck(command, browser);
- case 'uncheck':
- return await handleUncheck(command, browser);
- case 'upload':
- return await handleUpload(command, browser);
- case 'dblclick':
- return await handleDoubleClick(command, browser);
- case 'focus':
- return await handleFocus(command, browser);
- case 'drag':
- return await handleDrag(command, browser);
- case 'frame':
- return await handleFrame(command, browser);
- case 'mainframe':
- return await handleMainFrame(command, browser);
- case 'getbyrole':
- return await handleGetByRole(command, browser);
- case 'getbytext':
- return await handleGetByText(command, browser);
- case 'getbylabel':
- return await handleGetByLabel(command, browser);
- case 'getbyplaceholder':
- return await handleGetByPlaceholder(command, browser);
- case 'press':
- return await handlePress(command, browser);
- case 'screenshot':
- return await handleScreenshot(command, browser);
- case 'snapshot':
- return await handleSnapshot(command, browser);
- case 'evaluate':
- return await handleEvaluate(command, browser);
- case 'wait':
- return await handleWait(command, browser);
- case 'scroll':
- return await handleScroll(command, browser);
- case 'select':
- return await handleSelect(command, browser);
- case 'hover':
- return await handleHover(command, browser);
- case 'content':
- return await handleContent(command, browser);
- case 'close':
- return await handleClose(command, browser);
- case 'tab_new':
- return await handleTabNew(command, browser);
- case 'tab_list':
- return await handleTabList(command, browser);
- case 'tab_switch':
- return await handleTabSwitch(command, browser);
- case 'tab_close':
- return await handleTabClose(command, browser);
- case 'window_new':
- return await handleWindowNew(command, browser);
- case 'cookies_get':
- return await handleCookiesGet(command, browser);
- case 'cookies_set':
- return await handleCookiesSet(command, browser);
- case 'cookies_clear':
- return await handleCookiesClear(command, browser);
- case 'storage_get':
- return await handleStorageGet(command, browser);
- case 'storage_set':
- return await handleStorageSet(command, browser);
- case 'storage_clear':
- return await handleStorageClear(command, browser);
- case 'dialog':
- return await handleDialog(command, browser);
- case 'pdf':
- return await handlePdf(command, browser);
- case 'route':
- return await handleRoute(command, browser);
- case 'unroute':
- return await handleUnroute(command, browser);
- case 'requests':
- return await handleRequests(command, browser);
- case 'download':
- return await handleDownload(command, browser);
- case 'geolocation':
- return await handleGeolocation(command, browser);
- case 'permissions':
- return await handlePermissions(command, browser);
- case 'viewport':
- return await handleViewport(command, browser);
- case 'useragent':
- return await handleUserAgent(command, browser);
- case 'device':
- return await handleDevice(command, browser);
- case 'back':
- return await handleBack(command, browser);
- case 'forward':
- return await handleForward(command, browser);
- case 'reload':
- return await handleReload(command, browser);
- case 'url':
- return await handleUrl(command, browser);
- case 'cdp_url':
- return handleCdpUrl(command, browser);
- case 'inspect':
- return await handleInspect(command, browser);
- case 'title':
- return await handleTitle(command, browser);
- case 'getattribute':
- return await handleGetAttribute(command, browser);
- case 'gettext':
- return await handleGetText(command, browser);
- case 'isvisible':
- return await handleIsVisible(command, browser);
- case 'isenabled':
- return await handleIsEnabled(command, browser);
- case 'ischecked':
- return await handleIsChecked(command, browser);
- case 'count':
- return await handleCount(command, browser);
- case 'boundingbox':
- return await handleBoundingBox(command, browser);
- case 'styles':
- return await handleStyles(command, browser);
- case 'video_start':
- return await handleVideoStart(command, browser);
- case 'video_stop':
- return await handleVideoStop(command, browser);
- case 'trace_start':
- return await handleTraceStart(command, browser);
- case 'trace_stop':
- return await handleTraceStop(command, browser);
- case 'profiler_start':
- return await handleProfilerStart(command, browser);
- case 'profiler_stop':
- return await handleProfilerStop(command, browser);
- case 'har_start':
- return await handleHarStart(command, browser);
- case 'har_stop':
- return await handleHarStop(command, browser);
- case 'state_save':
- return await handleStateSave(command, browser);
- case 'state_load':
- return await handleStateLoad(command, browser);
- case 'state_list':
- return await handleStateList(command);
- case 'state_clear':
- return await handleStateClear(command);
- case 'state_show':
- return await handleStateShow(command);
- case 'state_clean':
- return await handleStateClean(command);
- case 'state_rename':
- return await handleStateRename(command);
- case 'console':
- return await handleConsole(command, browser);
- case 'errors':
- return await handleErrors(command, browser);
- case 'keyboard':
- return await handleKeyboard(command, browser);
- case 'wheel':
- return await handleWheel(command, browser);
- case 'tap':
- return await handleTap(command, browser);
- case 'clipboard':
- return await handleClipboard(command, browser);
- case 'highlight':
- return await handleHighlight(command, browser);
- case 'clear':
- return await handleClear(command, browser);
- case 'selectall':
- return await handleSelectAll(command, browser);
- case 'innertext':
- return await handleInnerText(command, browser);
- case 'innerhtml':
- return await handleInnerHtml(command, browser);
- case 'inputvalue':
- return await handleInputValue(command, browser);
- case 'setvalue':
- return await handleSetValue(command, browser);
- case 'dispatch':
- return await handleDispatch(command, browser);
- case 'evalhandle':
- return await handleEvalHandle(command, browser);
- case 'expose':
- return await handleExpose(command, browser);
- case 'addscript':
- return await handleAddScript(command, browser);
- case 'addstyle':
- return await handleAddStyle(command, browser);
- case 'emulatemedia':
- return await handleEmulateMedia(command, browser);
- case 'offline':
- return await handleOffline(command, browser);
- case 'headers':
- return await handleHeaders(command, browser);
- case 'pause':
- return await handlePause(command, browser);
- case 'getbyalttext':
- return await handleGetByAltText(command, browser);
- case 'getbytitle':
- return await handleGetByTitle(command, browser);
- case 'getbytestid':
- return await handleGetByTestId(command, browser);
- case 'nth':
- return await handleNth(command, browser);
- case 'waitforurl':
- return await handleWaitForUrl(command, browser);
- case 'waitforloadstate':
- return await handleWaitForLoadState(command, browser);
- case 'setcontent':
- return await handleSetContent(command, browser);
- case 'timezone':
- return await handleTimezone(command, browser);
- case 'locale':
- return await handleLocale(command, browser);
- case 'credentials':
- return await handleCredentials(command, browser);
- case 'mousemove':
- return await handleMouseMove(command, browser);
- case 'mousedown':
- return await handleMouseDown(command, browser);
- case 'mouseup':
- return await handleMouseUp(command, browser);
- case 'bringtofront':
- return await handleBringToFront(command, browser);
- case 'waitforfunction':
- return await handleWaitForFunction(command, browser);
- case 'scrollintoview':
- return await handleScrollIntoView(command, browser);
- case 'addinitscript':
- return await handleAddInitScript(command, browser);
- case 'keydown':
- return await handleKeyDown(command, browser);
- case 'keyup':
- return await handleKeyUp(command, browser);
- case 'inserttext':
- return await handleInsertText(command, browser);
- case 'multiselect':
- return await handleMultiSelect(command, browser);
- case 'waitfordownload':
- return await handleWaitForDownload(command, browser);
- case 'responsebody':
- return await handleResponseBody(command, browser);
- case 'screencast_start':
- return await handleScreencastStart(command, browser);
- case 'screencast_stop':
- return await handleScreencastStop(command, browser);
- case 'input_mouse':
- return await handleInputMouse(command, browser);
- case 'input_keyboard':
- return await handleInputKeyboard(command, browser);
- case 'input_touch':
- return await handleInputTouch(command, browser);
- case 'recording_start':
- return await handleRecordingStart(command, browser);
- case 'recording_stop':
- return await handleRecordingStop(command, browser);
- case 'recording_restart':
- return await handleRecordingRestart(command, browser);
- case 'diff_snapshot':
- return await handleDiffSnapshot(command, browser);
- case 'diff_screenshot':
- return await handleDiffScreenshot(command, browser);
- case 'diff_url':
- return await handleDiffUrl(command, browser);
- case 'auth_login':
- return await handleAuthLogin(command, browser);
- default: {
- // TypeScript narrows to never here, but we handle it for safety
- const unknownCommand = command as { id: string; action: string };
- return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`);
- }
- }
-}
-
-async function handleLaunch(
- command: Command & { action: 'launch' },
- browser: BrowserManager
-): Promise {
- if (command.engine === 'lightpanda') {
- return errorResponse(command.id, 'Lightpanda engine requires --native mode');
- }
- await browser.launch(command);
- return successResponse(command.id, { launched: true });
-}
-
-async function handleNavigate(
- command: NavigateCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.navigate(command.url, {
- headers: command.headers,
- waitUntil: command.waitUntil,
- });
-
- return successResponse(command.id, result);
-}
-
-async function handleClick(command: ClickCommand, browser: BrowserManager): Promise {
- // Support both refs (@e1) and regular selectors
- const locator = browser.getLocator(command.selector);
-
- try {
- // If --new-tab flag is set, get the href and open in a new tab
- if (command.newTab) {
- const fullUrl = await locator.evaluate((el) => {
- const href = el.getAttribute('href');
- // URL and document.baseURI are available in the browser context
- return href
- ? new (globalThis as any).URL(href, (globalThis as any).document.baseURI).toString()
- : '';
- });
- if (!fullUrl) {
- throw new Error(
- `Element '${command.selector}' does not have an href attribute. --new-tab only works on links.`
- );
- }
-
- await browser.newTab();
- const newPage = browser.getPage();
- await newPage.goto(fullUrl);
-
- return successResponse(command.id, {
- clicked: true,
- newTab: true,
- url: fullUrl,
- });
- }
-
- await locator.click({
- button: command.button,
- clickCount: command.clickCount,
- delay: command.delay,
- });
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
-
- return successResponse(command.id, { clicked: true });
-}
-
-async function handleType(command: TypeCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
-
- try {
- if (command.clear) {
- await locator.fill('');
- }
-
- await locator.pressSequentially(command.text, {
- delay: command.delay,
- });
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
-
- return successResponse(command.id, { typed: true });
-}
-
-async function handlePress(command: PressCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
-
- if (command.selector) {
- await page.press(command.selector, command.key);
- } else {
- await page.keyboard.press(command.key);
- }
-
- return successResponse(command.id, { pressed: true });
-}
-
-const ANNOTATION_OVERLAY_ID = '__agent_browser_annotations__';
-
-async function removeAnnotationOverlay(page: Page): Promise {
- await page
- .evaluate(
- `(() => { const el = document.getElementById(${JSON.stringify(ANNOTATION_OVERLAY_ID)}); if (el) el.remove(); })()`
- )
- .catch(() => {});
-}
-
-async function handleScreenshot(
- command: ScreenshotCommand,
- browser: BrowserManager
-): Promise> {
- const page = browser.getPage();
-
- const options: Parameters[0] = {
- fullPage: command.fullPage,
- type: command.format ?? 'png',
- };
-
- if (command.format === 'jpeg' && command.quality !== undefined) {
- options.quality = command.quality;
- }
-
- let target: Page | ReturnType = page;
- if (command.selector) {
- target = browser.getLocator(command.selector);
- }
-
- let overlayInjected = false;
-
- try {
- let savePath = command.path;
- if (!savePath) {
- const ext = command.format === 'jpeg' ? 'jpg' : 'png';
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
- const random = Math.random().toString(36).substring(2, 8);
- const filename = `screenshot-${timestamp}-${random}.${ext}`;
- const screenshotDir = command.screenshotDir ?? path.join(getAppDir(), 'tmp', 'screenshots');
- mkdirSync(screenshotDir, { recursive: true });
- savePath = path.join(screenshotDir, filename);
- }
-
- let annotations: Annotation[] | undefined;
-
- if (command.annotate) {
- const { refs } = await browser.getSnapshot({ interactive: true });
-
- const entries = Object.entries(refs);
- const results = await Promise.all(
- entries.map(async ([ref, data]): Promise => {
- try {
- const locator = browser.getLocatorFromRef(ref);
- if (!locator) return null;
- const box = await locator.boundingBox();
- if (!box || box.width === 0 || box.height === 0) return null;
- const num = parseInt(ref.replace('e', ''), 10);
- return {
- ref,
- number: num,
- role: data.role,
- name: data.name || undefined,
- box: {
- x: Math.round(box.x),
- y: Math.round(box.y),
- width: Math.round(box.width),
- height: Math.round(box.height),
- },
- };
- } catch {
- return null;
- }
- })
- );
-
- // When a selector is provided the screenshot is cropped to that element,
- // so filter to annotations that overlap the target and shift coordinates.
- let targetBox: { x: number; y: number; width: number; height: number } | null = null;
- if (command.selector) {
- const raw = await browser.getLocator(command.selector).boundingBox();
- if (raw) {
- targetBox = {
- x: Math.round(raw.x),
- y: Math.round(raw.y),
- width: Math.round(raw.width),
- height: Math.round(raw.height),
- };
- }
- }
-
- const filtered = results.filter((a): a is Annotation => a !== null);
-
- // Filter by selector overlap if needed, but keep viewport-relative coords
- // for overlay positioning. Coordinate shifting happens later for metadata only.
- let overlayItems: Annotation[];
- if (targetBox) {
- const tb = targetBox;
- overlayItems = filtered
- .filter((a) => {
- const ax2 = a.box.x + a.box.width;
- const ay2 = a.box.y + a.box.height;
- const bx2 = tb.x + tb.width;
- const by2 = tb.y + tb.height;
- return a.box.x < bx2 && ax2 > tb.x && a.box.y < by2 && ay2 > tb.y;
- })
- .sort((a, b) => a.number - b.number);
- } else {
- overlayItems = filtered.sort((a, b) => a.number - b.number);
- }
-
- if (overlayItems.length > 0) {
- const overlayData = overlayItems.map((a) => ({
- number: a.number,
- x: a.box.x,
- y: a.box.y,
- width: a.box.width,
- height: a.box.height,
- }));
-
- // Uses position:absolute with document-relative coords so labels render
- // correctly for both viewport and fullPage screenshots, and when the
- // screenshot is scoped to a selector element.
- await page.evaluate(`(() => {
- var items = ${JSON.stringify(overlayData)};
- var id = ${JSON.stringify(ANNOTATION_OVERLAY_ID)};
- var sx = window.scrollX || 0;
- var sy = window.scrollY || 0;
- var c = document.createElement('div');
- c.id = id;
- c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
- for (var i = 0; i < items.length; i++) {
- var it = items[i];
- var dx = it.x + sx;
- var dy = it.y + sy;
- var b = document.createElement('div');
- b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
- var l = document.createElement('div');
- l.textContent = String(it.number);
- var labelTop = dy < 14 ? '2px' : '-14px';
- l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
- b.appendChild(l);
- c.appendChild(b);
- }
- document.documentElement.appendChild(c);
- })()`);
- overlayInjected = true;
- }
-
- // Build returned annotation metadata with image-relative coordinates.
- // Selector: shift to target-element-relative.
- // fullPage: convert to document-relative (matching fullPage image origin).
- // Default: viewport-relative (unchanged).
- if (targetBox) {
- const tb = targetBox;
- annotations = overlayItems.map((a) => ({
- ...a,
- box: {
- x: a.box.x - tb.x,
- y: a.box.y - tb.y,
- width: a.box.width,
- height: a.box.height,
- },
- }));
- } else if (command.fullPage) {
- const scroll = (await page.evaluate(
- `({x: window.scrollX || 0, y: window.scrollY || 0})`
- )) as { x: number; y: number };
- annotations = overlayItems.map((a) => ({
- ...a,
- box: {
- x: a.box.x + scroll.x,
- y: a.box.y + scroll.y,
- width: a.box.width,
- height: a.box.height,
- },
- }));
- } else {
- annotations = overlayItems;
- }
- }
-
- await target.screenshot({ ...options, path: savePath });
-
- if (overlayInjected) {
- await removeAnnotationOverlay(page);
- }
-
- return successResponse(command.id, {
- path: savePath,
- ...(annotations && annotations.length > 0 ? { annotations } : {}),
- });
- } catch (error) {
- if (overlayInjected) {
- await removeAnnotationOverlay(page);
- }
- if (command.selector) {
- throw toAIFriendlyError(error, command.selector);
- }
- throw error;
- }
-}
-
-async function handleSnapshot(
- command: Command & {
- action: 'snapshot';
- interactive?: boolean;
- cursor?: boolean;
- maxDepth?: number;
- compact?: boolean;
- selector?: string;
- },
- browser: BrowserManager
-): Promise> {
- // Use enhanced snapshot with refs and optional filtering
- const { tree, refs } = await browser.getSnapshot({
- interactive: command.interactive,
- cursor: command.cursor,
- maxDepth: command.maxDepth,
- compact: command.compact,
- selector: command.selector,
- });
-
- // Simplify refs for output (just role and name)
- const simpleRefs: Record = {};
- for (const [ref, data] of Object.entries(refs)) {
- simpleRefs[ref] = { role: data.role, name: data.name };
- }
-
- const page = browser.getPage();
- return successResponse(command.id, {
- snapshot: tree || 'Empty page',
- refs: Object.keys(simpleRefs).length > 0 ? simpleRefs : undefined,
- origin: page.url(),
- });
-}
-
-async function handleEvaluate(
- command: EvaluateCommand,
- browser: BrowserManager
-): Promise> {
- const page = browser.getPage();
-
- // Evaluate the script directly as a string expression
- const result = await page.evaluate(command.script);
-
- return successResponse(command.id, { result, origin: page.url() });
-}
-
-async function handleWait(command: WaitCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
-
- if (command.text) {
- await page.waitForFunction(
- `(document.body.innerText || '').includes(${JSON.stringify(command.text)})`,
- { timeout: command.timeout }
- );
- } else if (command.selector) {
- await page.waitForSelector(command.selector, {
- state: command.state ?? 'visible',
- timeout: command.timeout,
- });
- } else if (command.timeout) {
- await page.waitForTimeout(command.timeout);
- } else {
- await page.waitForLoadState('load');
- }
-
- return successResponse(command.id, { waited: true });
-}
-
-async function handleScroll(command: ScrollCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
-
- let deltaX = command.x ?? 0;
- let deltaY = command.y ?? 0;
- const hasExplicitDelta = command.x !== undefined || command.y !== undefined;
-
- if (command.direction) {
- const amount = command.amount ?? 100;
- switch (command.direction) {
- case 'up':
- deltaY = -amount;
- break;
- case 'down':
- deltaY = amount;
- break;
- case 'left':
- deltaX = -amount;
- break;
- case 'right':
- deltaX = amount;
- break;
- }
- }
-
- if (command.selector) {
- const element = browser.getLocator(command.selector);
- await element.scrollIntoViewIfNeeded();
-
- if (hasExplicitDelta || deltaX !== 0 || deltaY !== 0) {
- await element.evaluate(
- (el, { x, y }) => {
- el.scrollBy(x, y);
- },
- { x: deltaX, y: deltaY }
- );
- }
- } else {
- await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
- }
-
- return successResponse(command.id, { scrolled: true });
-}
-
-async function handleSelect(command: SelectCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- const values = Array.isArray(command.values) ? command.values : [command.values];
-
- try {
- await locator.selectOption(values);
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
-
- return successResponse(command.id, { selected: values });
-}
-
-async function handleHover(command: HoverCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- try {
- await locator.hover();
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
-
- return successResponse(command.id, { hovered: true });
-}
-
-async function handleContent(
- command: ContentCommand,
- browser: BrowserManager
-): Promise> {
- const page = browser.getPage();
-
- let html: string;
- if (command.selector) {
- const locator = browser.getLocator(command.selector);
- html = await locator.innerHTML();
- } else {
- html = await page.content();
- }
-
- return successResponse(command.id, { html, origin: page.url() });
-}
-
-async function handleClose(
- command: Command & { action: 'close' },
- browser: BrowserManager
-): Promise {
- await browser.close();
- return successResponse(command.id, { closed: true });
-}
-
-async function handleTabNew(
- command: TabNewCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.newTab();
-
- // Navigate to URL if provided (same pattern as handleNavigate)
- if (command.url) {
- const page = browser.getPage();
- await page.goto(command.url, { waitUntil: 'domcontentloaded' });
- }
-
- return successResponse(command.id, result);
-}
-
-async function handleTabList(
- command: Command & { action: 'tab_list' },
- browser: BrowserManager
-): Promise> {
- const tabs = await browser.listTabs();
- return successResponse(command.id, {
- tabs,
- active: browser.getActiveIndex(),
- });
-}
-
-async function handleTabSwitch(
- command: TabSwitchCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.switchTo(command.index);
- const page = browser.getPage();
- return successResponse(command.id, {
- ...result,
- title: await page.title(),
- });
-}
-
-async function handleTabClose(
- command: TabCloseCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.closeTab(command.index);
- return successResponse(command.id, result);
-}
-
-async function handleWindowNew(
- command: WindowNewCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.newWindow(command.viewport);
- return successResponse(command.id, result);
-}
-
-// New handlers for enhanced Playwright parity
-
-async function handleFill(command: FillCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- try {
- await locator.fill(command.value);
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
- return successResponse(command.id, { filled: true });
-}
-
-async function handleCheck(command: CheckCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- try {
- await locator.check();
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
- return successResponse(command.id, { checked: true });
-}
-
-async function handleUncheck(command: UncheckCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- try {
- await locator.uncheck();
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
- return successResponse(command.id, { unchecked: true });
-}
-
-async function handleUpload(command: UploadCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- const files = Array.isArray(command.files) ? command.files : [command.files];
- try {
- await locator.setInputFiles(files);
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
- return successResponse(command.id, { uploaded: files });
-}
-
-async function handleDoubleClick(
- command: DoubleClickCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- try {
- await locator.dblclick();
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
- return successResponse(command.id, { clicked: true });
-}
-
-async function handleFocus(command: FocusCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- try {
- await locator.focus();
- } catch (error) {
- throw toAIFriendlyError(error, command.selector);
- }
- return successResponse(command.id, { focused: true });
-}
-
-async function handleDrag(command: DragCommand, browser: BrowserManager): Promise {
- const frame = browser.getFrame();
- await frame.dragAndDrop(command.source, command.target);
- return successResponse(command.id, { dragged: true });
-}
-
-async function handleFrame(command: FrameCommand, browser: BrowserManager): Promise {
- await browser.switchToFrame({
- selector: command.selector,
- name: command.name,
- url: command.url,
- });
- return successResponse(command.id, { switched: true });
-}
-
-async function handleMainFrame(
- command: Command & { action: 'mainframe' },
- browser: BrowserManager
-): Promise {
- browser.switchToMainFrame();
- return successResponse(command.id, { switched: true });
-}
-
-async function handleGetByRole(
- command: GetByRoleCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByRole(command.role as any, { name: command.name, exact: command.exact });
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'fill':
- await locator.fill(command.value ?? '');
- return successResponse(command.id, { filled: true });
- case 'check':
- await locator.check();
- return successResponse(command.id, { checked: true });
- case 'hover':
- await locator.hover();
- return successResponse(command.id, { hovered: true });
- }
-}
-
-async function handleGetByText(
- command: GetByTextCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByText(command.text, { exact: command.exact });
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'hover':
- await locator.hover();
- return successResponse(command.id, { hovered: true });
- }
-}
-
-async function handleGetByLabel(
- command: GetByLabelCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByLabel(command.label, { exact: command.exact });
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'fill':
- await locator.fill(command.value ?? '');
- return successResponse(command.id, { filled: true });
- case 'check':
- await locator.check();
- return successResponse(command.id, { checked: true });
- }
-}
-
-async function handleGetByPlaceholder(
- command: GetByPlaceholderCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByPlaceholder(command.placeholder, { exact: command.exact });
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'fill':
- await locator.fill(command.value ?? '');
- return successResponse(command.id, { filled: true });
- }
-}
-
-async function handleCookiesGet(
- command: Command & { action: 'cookies_get'; urls?: string[] },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const context = page.context();
- const cookies = await context.cookies(command.urls);
- return successResponse(command.id, { cookies });
-}
-
-async function handleCookiesSet(
- command: CookiesSetCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const context = page.context();
- // Auto-fill URL for cookies that don't have domain/path/url set
- const pageUrl = page.url();
- const cookies = command.cookies.map((cookie) => {
- if (!cookie.url && !cookie.domain && !cookie.path) {
- return { ...cookie, url: pageUrl };
- }
- return cookie;
- });
- await context.addCookies(cookies);
- return successResponse(command.id, { set: true });
-}
-
-async function handleCookiesClear(
- command: Command & { action: 'cookies_clear' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const context = page.context();
- await context.clearCookies();
- return successResponse(command.id, { cleared: true });
-}
-
-async function handleStorageGet(
- command: StorageGetCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const storageType = command.type === 'local' ? 'localStorage' : 'sessionStorage';
-
- if (command.key) {
- const value = await page.evaluate(`${storageType}.getItem(${JSON.stringify(command.key)})`);
- return successResponse(command.id, { key: command.key, value });
- } else {
- const data = await page.evaluate(`
- (() => {
- const storage = ${storageType};
- const result = {};
- for (let i = 0; i < storage.length; i++) {
- const key = storage.key(i);
- if (key) result[key] = storage.getItem(key);
- }
- return result;
- })()
- `);
- return successResponse(command.id, { data });
- }
-}
-
-async function handleStorageSet(
- command: StorageSetCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const storageType = command.type === 'local' ? 'localStorage' : 'sessionStorage';
-
- await page.evaluate(
- `${storageType}.setItem(${JSON.stringify(command.key)}, ${JSON.stringify(command.value)})`
- );
- return successResponse(command.id, { set: true });
-}
-
-async function handleStorageClear(
- command: StorageClearCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const storageType = command.type === 'local' ? 'localStorage' : 'sessionStorage';
-
- await page.evaluate(`${storageType}.clear()`);
- return successResponse(command.id, { cleared: true });
-}
-
-async function handleDialog(command: DialogCommand, browser: BrowserManager): Promise {
- browser.setDialogHandler(command.response, command.promptText);
- return successResponse(command.id, { handler: 'set', response: command.response });
-}
-
-async function handlePdf(command: PdfCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
- await page.pdf({
- path: command.path,
- format: command.format ?? 'Letter',
- });
- return successResponse(command.id, { path: command.path });
-}
-
-// Network & Request handlers
-
-async function handleRoute(command: RouteCommand, browser: BrowserManager): Promise {
- await browser.addRoute(command.url, {
- response: command.response,
- abort: command.abort,
- });
- return successResponse(command.id, { routed: command.url });
-}
-
-async function handleUnroute(
- command: Command & { action: 'unroute'; url?: string },
- browser: BrowserManager
-): Promise {
- await browser.removeRoute(command.url);
- return successResponse(command.id, { unrouted: command.url ?? 'all' });
-}
-
-async function handleRequests(
- command: RequestsCommand,
- browser: BrowserManager
-): Promise {
- if (command.clear) {
- browser.clearRequests();
- return successResponse(command.id, { cleared: true });
- }
-
- // Start tracking if not already
- browser.startRequestTracking();
-
- const requests = browser.getRequests(command.filter);
- return successResponse(command.id, { requests });
-}
-
-async function handleDownload(
- command: DownloadCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = browser.getLocator(command.selector);
-
- const [download] = await Promise.all([page.waitForEvent('download'), locator.click()]);
-
- await download.saveAs(command.path);
- return successResponse(command.id, {
- path: command.path,
- suggestedFilename: download.suggestedFilename(),
- });
-}
-
-async function handleGeolocation(
- command: GeolocationCommand,
- browser: BrowserManager
-): Promise {
- await browser.setGeolocation(command.latitude, command.longitude, command.accuracy);
- return successResponse(command.id, {
- latitude: command.latitude,
- longitude: command.longitude,
- });
-}
-
-async function handlePermissions(
- command: PermissionsCommand,
- browser: BrowserManager
-): Promise {
- await browser.setPermissions(command.permissions, command.grant);
- return successResponse(command.id, {
- permissions: command.permissions,
- granted: command.grant,
- });
-}
-
-async function handleViewport(
- command: ViewportCommand,
- browser: BrowserManager
-): Promise {
- if (command.deviceScaleFactor && command.deviceScaleFactor !== 1) {
- await browser.setViewport(command.width, command.height);
- await browser.setDeviceScaleFactor(
- command.deviceScaleFactor,
- command.width,
- command.height,
- false
- );
- } else {
- // deviceScaleFactor is 1 or undefined -- clear any previously-set CDP
- // Emulation.setDeviceMetricsOverride so stale DPR doesn't persist.
- try {
- await browser.clearDeviceMetricsOverride();
- } catch {
- // Ignore if override was never set
- }
- await browser.setViewport(command.width, command.height);
- }
-
- const result: Record = {
- width: command.width,
- height: command.height,
- };
- if (command.deviceScaleFactor !== undefined) {
- result.deviceScaleFactor = command.deviceScaleFactor;
- }
- return successResponse(command.id, result);
-}
-
-async function handleUserAgent(
- command: Command & { action: 'useragent'; userAgent: string },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const context = page.context();
- // Note: Can't change user agent after context is created, but we can for new pages
- return successResponse(command.id, {
- note: 'User agent can only be set at launch time. Use device command instead.',
- });
-}
-
-async function handleDevice(command: DeviceCommand, browser: BrowserManager): Promise {
- const device = browser.getDevice(command.device);
- if (!device) {
- const available = browser.listDevices().slice(0, 10).join(', ');
- throw new Error(`Unknown device: ${command.device}. Available: ${available}...`);
- }
-
- // Apply device viewport
- await browser.setViewport(device.viewport.width, device.viewport.height);
-
- // Apply or clear device scale factor
- if (device.deviceScaleFactor && device.deviceScaleFactor !== 1) {
- // Apply device scale factor for HiDPI/retina displays
- await browser.setDeviceScaleFactor(
- device.deviceScaleFactor,
- device.viewport.width,
- device.viewport.height,
- device.isMobile ?? false
- );
- } else {
- // Clear device scale factor override to restore default (1x)
- try {
- await browser.clearDeviceMetricsOverride();
- } catch {
- // Ignore error if override was never set
- }
- }
-
- return successResponse(command.id, {
- device: command.device,
- viewport: device.viewport,
- userAgent: device.userAgent,
- deviceScaleFactor: device.deviceScaleFactor,
- });
-}
-
-async function handleBack(
- command: Command & { action: 'back' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.goBack();
- return successResponse(command.id, { url: page.url() });
-}
-
-async function handleForward(
- command: Command & { action: 'forward' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.goForward();
- return successResponse(command.id, { url: page.url() });
-}
-
-async function handleReload(
- command: Command & { action: 'reload' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.reload();
- return successResponse(command.id, { url: page.url() });
-}
-
-async function handleUrl(
- command: Command & { action: 'url' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- return successResponse(command.id, { url: page.url() });
-}
-
-function handleCdpUrl(command: Command & { action: 'cdp_url' }, browser: BrowserManager): Response {
- const cdpUrl = browser.getCdpUrl();
- if (!cdpUrl) {
- return errorResponse(command.id, 'CDP URL not available (browser may not be launched)');
- }
- return successResponse(command.id, { cdpUrl });
-}
-
-async function handleInspect(
- command: Command & { action: 'inspect' },
- browser: BrowserManager
-): Promise {
- const cdpUrl = browser.getCdpUrl();
- if (!cdpUrl) {
- return errorResponse(command.id, 'CDP URL not available (browser may not be launched)');
- }
-
- // Shut down any existing inspect server so we always target the current page
- browser.stopInspectServer();
-
- const stripped = cdpUrl.replace(/^(wss?|https?):\/\//, '');
- const hostPort = stripped.split('/')[0];
-
- // Get the target ID so the inspect server can create its own dedicated CDP session
- const page = browser.getPage();
- const context = page.context();
- const tmpCdp = await context.newCDPSession(page);
- let targetId = '';
- try {
- const info: any = await tmpCdp.send('Target.getTargetInfo' as any);
- targetId = info?.targetInfo?.targetId || '';
- } catch (err) {
- console.error('[inspect] getTargetInfo failed:', err);
- }
- await tmpCdp.detach();
-
- if (!targetId) {
- return errorResponse(command.id, 'Could not determine target ID for active page');
- }
-
- const { InspectServer } = await import('./inspect-server.js');
- const server = new InspectServer({
- chromeHostPort: hostPort,
- targetId,
- chromeWsUrl: cdpUrl,
- });
- await server.start();
- browser.setInspectServer(server);
-
- const url = `http://127.0.0.1:${server.port}`;
- openUrlInBrowser(url);
- return successResponse(command.id, { opened: true, url });
-}
-
-function openUrlInBrowser(url: string): void {
- const platform = process.platform;
- const cmd =
- platform === 'darwin'
- ? `open "${url}"`
- : platform === 'win32'
- ? `start "" "${url}"`
- : `xdg-open "${url}"`;
- exec(cmd, (err) => {
- if (err) console.error('[inspect] Failed to open browser:', err.message);
- });
-}
-
-async function handleTitle(
- command: Command & { action: 'title' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const title = await page.title();
- return successResponse(command.id, { title });
-}
-
-async function handleGetAttribute(
- command: GetAttributeCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = browser.getLocator(command.selector);
- const value = await locator.getAttribute(command.attribute);
- return successResponse(command.id, { attribute: command.attribute, value, origin: page.url() });
-}
-
-async function handleGetText(command: GetTextCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
- const locator = browser.getLocator(command.selector);
- const inner = await locator.innerText();
- const text = inner || (await locator.textContent()) || '';
- return successResponse(command.id, { text, origin: page.url() });
-}
-
-async function handleIsVisible(
- command: IsVisibleCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- const visible = await locator.isVisible();
- return successResponse(command.id, { visible });
-}
-
-async function handleIsEnabled(
- command: IsEnabledCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- const enabled = await locator.isEnabled();
- return successResponse(command.id, { enabled });
-}
-
-async function handleIsChecked(
- command: IsCheckedCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- const checked = await locator.isChecked();
- return successResponse(command.id, { checked });
-}
-
-async function handleCount(command: CountCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- const count = await locator.count();
- return successResponse(command.id, { count });
-}
-
-async function handleBoundingBox(
- command: BoundingBoxCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- const box = await locator.boundingBox();
- return successResponse(command.id, { box });
-}
-
-async function handleStyles(
- command: StylesCommand,
- browser: BrowserManager
-): Promise> {
- const page = browser.getPage();
-
- // Shared extraction logic as a string to be eval'd in browser context
- const extractStylesScript = `(function(el) {
- const s = getComputedStyle(el);
- const r = el.getBoundingClientRect();
- return {
- tag: el.tagName.toLowerCase(),
- text: el.innerText?.trim().slice(0, 80) || null,
- box: {
- x: Math.round(r.x),
- y: Math.round(r.y),
- width: Math.round(r.width),
- height: Math.round(r.height),
- },
- styles: {
- fontSize: s.fontSize,
- fontWeight: s.fontWeight,
- fontFamily: s.fontFamily.split(',')[0].trim().replace(/"/g, ''),
- color: s.color,
- backgroundColor: s.backgroundColor,
- borderRadius: s.borderRadius,
- border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null,
- boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null,
- padding: s.padding,
- },
- };
- })`;
-
- // Check if it's a ref - single element
- if (browser.isRef(command.selector)) {
- const locator = browser.getLocator(command.selector);
- const element = (await locator.evaluate((el, script) => {
- const fn = eval(script);
- return fn(el);
- }, extractStylesScript)) as StylesData['elements'][0];
- return successResponse(command.id, { elements: [element] });
- }
-
- // CSS selector - can match multiple elements
- const elements = (await page.$$eval(
- command.selector,
- (els, script) => {
- const fn = eval(script);
- return els.map((el) => fn(el));
- },
- extractStylesScript
- )) as StylesData['elements'];
-
- return successResponse(command.id, { elements });
-}
-
-// Advanced handlers
-
-async function handleVideoStart(
- command: Command & { action: 'video_start'; path: string },
- browser: BrowserManager
-): Promise {
- // Video recording requires context-level setup at launch
- // For now, return a note about this limitation
- return successResponse(command.id, {
- note: 'Video recording must be enabled at browser launch. Use --video flag when starting.',
- path: command.path,
- });
-}
-
-async function handleVideoStop(
- command: Command & { action: 'video_stop' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const video = page.video();
- if (video) {
- const path = await video.path();
- return successResponse(command.id, { path });
- }
- return successResponse(command.id, { note: 'No video recording active' });
-}
-
-async function handleTraceStart(
- command: TraceStartCommand,
- browser: BrowserManager
-): Promise {
- await browser.startTracing({
- screenshots: command.screenshots,
- snapshots: command.snapshots,
- });
- return successResponse(command.id, { started: true });
-}
-
-async function handleTraceStop(
- command: TraceStopCommand,
- browser: BrowserManager
-): Promise {
- await browser.stopTracing(command.path);
- return successResponse(
- command.id,
- command.path ? { path: command.path } : { traceStopped: true }
- );
-}
-
-async function handleProfilerStart(
- command: ProfilerStartCommand,
- browser: BrowserManager
-): Promise {
- await browser.startProfiling({ categories: command.categories });
- return successResponse(command.id, { started: true });
-}
-
-async function handleProfilerStop(
- command: ProfilerStopCommand,
- browser: BrowserManager
-): Promise {
- let outputPath = command.path;
- if (!outputPath) {
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
- const random = Math.random().toString(36).substring(2, 8);
- const filename = `profile-${timestamp}-${random}.json`;
- const profileDir = path.join(getAppDir(), 'tmp', 'profiles');
- mkdirSync(profileDir, { recursive: true });
- outputPath = path.join(profileDir, filename);
- }
- const result = await browser.stopProfiling(outputPath);
- return successResponse(command.id, result);
-}
-
-async function handleHarStart(
- command: Command & { action: 'har_start' },
- browser: BrowserManager
-): Promise {
- await browser.startHarRecording();
- browser.startRequestTracking();
- return successResponse(command.id, { started: true });
-}
-
-async function handleHarStop(command: HarStopCommand, browser: BrowserManager): Promise {
- // HAR recording is handled at context level
- // For now, we save tracked requests as a simplified HAR-like format
- const requests = browser.getRequests();
- return successResponse(command.id, {
- path: command.path,
- requestCount: requests.length,
- });
-}
-
-async function handleStateSave(
- command: StorageStateSaveCommand,
- browser: BrowserManager
-): Promise {
- await browser.saveStorageState(command.path);
- return successResponse(command.id, { path: command.path });
-}
-
-async function handleStateLoad(
- command: Command & { action: 'state_load'; path: string },
- browser: BrowserManager
-): Promise {
- if (browser.isLaunched()) {
- return errorResponse(
- command.id,
- 'Cannot load state while browser is running. Close browser first, then relaunch with loaded state.'
- );
- }
-
- if (!fs.existsSync(command.path)) {
- return errorResponse(command.id, `State file not found: ${command.path}`);
- }
-
- await browser.launch({
- headless: true,
- autoStateFilePath: command.path,
- });
-
- return successResponse(command.id, {
- loaded: true,
- path: command.path,
- });
-}
-
-async function handleStateList(command: StateListCommand): Promise {
- const sessionsDir = getSessionsDir();
- const files = listStateFiles();
-
- if (files.length === 0) {
- return successResponse(command.id, { files: [], directory: sessionsDir });
- }
-
- const stateFiles = files
- .map((filename) => {
- const filepath = path.join(sessionsDir, filename);
- const stats = fs.statSync(filepath);
-
- let encrypted = false;
- try {
- const content = fs.readFileSync(filepath, 'utf-8');
- const parsed = JSON.parse(content);
- encrypted = isEncryptedPayload(parsed);
- } catch {
- // Ignore parse errors
- }
-
- return {
- filename,
- path: filepath,
- size: stats.size,
- modified: stats.mtime.toISOString(),
- encrypted,
- };
- })
- .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
-
- return successResponse(command.id, { files: stateFiles, directory: sessionsDir });
-}
-
-async function handleStateClear(command: StateClearCommand): Promise {
- const sessionsDir = getSessionsDir();
-
- if (command.sessionName && !isValidSessionName(command.sessionName)) {
- return errorResponse(
- command.id,
- 'Invalid session name. Use only letters, numbers, dashes, and underscores.'
- );
- }
-
- const files = listStateFiles();
- if (files.length === 0) {
- return successResponse(command.id, { cleared: 0, deleted: [] });
- }
-
- const deleted: string[] = [];
-
- if (command.all) {
- for (const file of files) {
- fs.unlinkSync(path.join(sessionsDir, file));
- deleted.push(file);
- }
- } else if (command.sessionName) {
- for (const file of files) {
- if (file.startsWith(`${command.sessionName}-`)) {
- fs.unlinkSync(path.join(sessionsDir, file));
- deleted.push(file);
- }
- }
- }
-
- return successResponse(command.id, { cleared: deleted.length, deleted });
-}
-
-async function handleStateShow(command: StateShowCommand): Promise {
- const sessionsDir = getSessionsDir();
-
- const baseName = command.filename.replace(/\.json$/, '');
- if (!command.filename.endsWith('.json') || !isValidSessionName(baseName)) {
- return errorResponse(
- command.id,
- 'Invalid filename. Use only letters, numbers, dashes, and underscores (with .json extension).'
- );
- }
-
- const filepath = path.join(sessionsDir, command.filename);
-
- if (!fs.existsSync(filepath)) {
- return errorResponse(command.id, `State file not found: ${command.filename}`);
- }
-
- try {
- const { data: state, wasEncrypted } = readStateFile(filepath);
- const stats = fs.statSync(filepath);
-
- const stateObj = state as {
- cookies?: Array<{ domain: string }>;
- origins?: unknown[];
- };
- const cookies = stateObj.cookies?.length || 0;
- const origins = stateObj.origins?.length || 0;
- const domains = [...new Set((stateObj.cookies || []).map((c) => c.domain))];
-
- return successResponse(command.id, {
- filename: command.filename,
- path: filepath,
- size: stats.size,
- modified: stats.mtime.toISOString(),
- encrypted: wasEncrypted,
- summary: {
- cookies,
- origins,
- domains,
- },
- state,
- });
- } catch (e) {
- return errorResponse(command.id, `Failed to parse state file: ${(e as Error).message}`);
- }
-}
-
-async function handleStateClean(command: StateCleanCommand): Promise {
- const deleted = cleanupExpiredStates(command.days);
- const keptCount = listStateFiles().length;
-
- return successResponse(command.id, {
- cleaned: deleted.length,
- deleted,
- keptCount,
- days: command.days,
- });
-}
-
-async function handleStateRename(command: StateRenameCommand): Promise {
- const sessionsDir = getSessionsDir();
-
- if (!isValidSessionName(command.oldName) || !isValidSessionName(command.newName)) {
- return errorResponse(
- command.id,
- 'Invalid name. Use only letters, numbers, dashes, and underscores.'
- );
- }
-
- const oldPath = path.join(sessionsDir, `${command.oldName}.json`);
- const newPath = path.join(sessionsDir, `${command.newName}.json`);
-
- if (!fs.existsSync(oldPath)) {
- return errorResponse(command.id, `State file not found: ${command.oldName}.json`);
- }
-
- if (fs.existsSync(newPath)) {
- return errorResponse(command.id, `Destination already exists: ${command.newName}.json`);
- }
-
- fs.renameSync(oldPath, newPath);
-
- return successResponse(command.id, {
- renamed: true,
- oldName: `${command.oldName}.json`,
- newName: `${command.newName}.json`,
- path: newPath,
- });
-}
-
-async function handleConsole(command: ConsoleCommand, browser: BrowserManager): Promise {
- if (command.clear) {
- browser.clearConsoleMessages();
- return successResponse(command.id, { cleared: true });
- }
-
- const page = browser.getPage();
- const messages = browser.getConsoleMessages();
- return successResponse(command.id, { messages, origin: page.url() });
-}
-
-async function handleErrors(command: ErrorsCommand, browser: BrowserManager): Promise {
- if (command.clear) {
- browser.clearPageErrors();
- return successResponse(command.id, { cleared: true });
- }
-
- const errors = browser.getPageErrors();
- return successResponse(command.id, { errors });
-}
-
-async function handleKeyboard(
- command: KeyboardCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const sub = command.subaction ?? 'press';
-
- switch (sub) {
- case 'type':
- await page.keyboard.type(command.text ?? '', { delay: command.delay });
- return successResponse(command.id, { typed: true, text: command.text });
- case 'press':
- await page.keyboard.press(command.keys ?? '');
- return successResponse(command.id, { pressed: command.keys });
- case 'insertText':
- await page.keyboard.insertText(command.text ?? '');
- return successResponse(command.id, { inserted: true, text: command.text });
- default:
- return errorResponse(command.id, `Unknown keyboard subaction: ${sub}`);
- }
-}
-
-async function handleWheel(command: WheelCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
-
- if (command.selector) {
- const element = browser.getLocator(command.selector);
- await element.hover();
- }
-
- await page.mouse.wheel(command.deltaX ?? 0, command.deltaY ?? 0);
- return successResponse(command.id, { scrolled: true });
-}
-
-async function handleTap(command: TapCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
- await page.tap(command.selector);
- return successResponse(command.id, { tapped: true });
-}
-
-async function handleClipboard(
- command: ClipboardCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
-
- switch (command.operation) {
- case 'copy':
- await page.keyboard.press('ControlOrMeta+c');
- return successResponse(command.id, { copied: true });
- case 'paste':
- await page.keyboard.press('ControlOrMeta+v');
- return successResponse(command.id, { pasted: true });
- case 'read': {
- const text = await page.evaluate('navigator.clipboard.readText()');
- return successResponse(command.id, { text });
- }
- case 'write': {
- if (!command.text) {
- return errorResponse(command.id, "Missing 'text' parameter for clipboard write");
- }
- await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(command.text)})`);
- return successResponse(command.id, { written: command.text });
- }
- default:
- return errorResponse(command.id, 'Unknown clipboard operation');
- }
-}
-
-async function handleHighlight(
- command: HighlightCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- await locator.highlight();
- return successResponse(command.id, { highlighted: true });
-}
-
-async function handleClear(command: ClearCommand, browser: BrowserManager): Promise {
- const locator = browser.getLocator(command.selector);
- await locator.clear();
- return successResponse(command.id, { cleared: true });
-}
-
-async function handleSelectAll(
- command: SelectAllCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- await locator.selectText();
- return successResponse(command.id, { selected: true });
-}
-
-async function handleInnerText(
- command: InnerTextCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- const text = await locator.innerText();
- return successResponse(command.id, { text });
-}
-
-async function handleInnerHtml(
- command: InnerHtmlCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = browser.getLocator(command.selector);
- const html = await locator.innerHTML();
- return successResponse(command.id, { html, origin: page.url() });
-}
-
-async function handleInputValue(
- command: InputValueCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = browser.getLocator(command.selector);
- const value = await locator.inputValue();
- return successResponse(command.id, { value, origin: page.url() });
-}
-
-async function handleSetValue(
- command: SetValueCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- await locator.fill(command.value);
- return successResponse(command.id, { set: true });
-}
-
-async function handleDispatch(
- command: DispatchEventCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- await locator.dispatchEvent(command.event, command.eventInit);
- return successResponse(command.id, { dispatched: command.event });
-}
-
-async function handleEvalHandle(
- command: Command & { action: 'evalhandle'; script: string },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const handle = await page.evaluateHandle(command.script);
- const result = await handle.jsonValue().catch(() => 'Handle (non-serializable)');
- return successResponse(command.id, { result });
-}
-
-async function handleExpose(
- command: Command & { action: 'expose'; name: string },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.exposeFunction(command.name, () => {
- // Exposed function - can be extended
- return `Function ${command.name} called`;
- });
- return successResponse(command.id, { exposed: command.name });
-}
-
-async function handleAddScript(
- command: AddScriptCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
-
- if (command.content) {
- await page.addScriptTag({ content: command.content });
- } else if (command.url) {
- await page.addScriptTag({ url: command.url });
- }
-
- return successResponse(command.id, { added: true });
-}
-
-async function handleAddStyle(
- command: AddStyleCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
-
- if (command.content) {
- await page.addStyleTag({ content: command.content });
- } else if (command.url) {
- await page.addStyleTag({ url: command.url });
- }
-
- return successResponse(command.id, { added: true });
-}
-
-async function handleEmulateMedia(
- command: EmulateMediaCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.emulateMedia({
- media: command.media,
- colorScheme: command.colorScheme,
- reducedMotion: command.reducedMotion,
- forcedColors: command.forcedColors,
- });
- if (command.colorScheme) {
- browser.setColorScheme(command.colorScheme);
- }
- return successResponse(command.id, { emulated: true });
-}
-
-async function handleOffline(command: OfflineCommand, browser: BrowserManager): Promise {
- await browser.setOffline(command.offline);
- return successResponse(command.id, { offline: command.offline });
-}
-
-async function handleHeaders(command: HeadersCommand, browser: BrowserManager): Promise {
- await browser.setExtraHeaders(command.headers);
- return successResponse(command.id, { set: true });
-}
-
-async function handlePause(
- command: Command & { action: 'pause' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.pause();
- return successResponse(command.id, { paused: true });
-}
-
-async function handleGetByAltText(
- command: GetByAltTextCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByAltText(command.text, { exact: command.exact });
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'hover':
- await locator.hover();
- return successResponse(command.id, { hovered: true });
- }
-}
-
-async function handleGetByTitle(
- command: GetByTitleCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByTitle(command.text, { exact: command.exact });
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'hover':
- await locator.hover();
- return successResponse(command.id, { hovered: true });
- }
-}
-
-async function handleGetByTestId(
- command: GetByTestIdCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const locator = page.getByTestId(command.testId);
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'fill':
- await locator.fill(command.value ?? '');
- return successResponse(command.id, { filled: true });
- case 'check':
- await locator.check();
- return successResponse(command.id, { checked: true });
- case 'hover':
- await locator.hover();
- return successResponse(command.id, { hovered: true });
- }
-}
-
-async function handleNth(command: NthCommand, browser: BrowserManager): Promise {
- const base = browser.getLocator(command.selector);
- const locator = command.index === -1 ? base.last() : base.nth(command.index);
-
- switch (command.subaction) {
- case 'click':
- await locator.click();
- return successResponse(command.id, { clicked: true });
- case 'fill':
- await locator.fill(command.value ?? '');
- return successResponse(command.id, { filled: true });
- case 'check':
- await locator.check();
- return successResponse(command.id, { checked: true });
- case 'hover':
- await locator.hover();
- return successResponse(command.id, { hovered: true });
- case 'text':
- const text = await locator.textContent();
- return successResponse(command.id, { text });
- }
-}
-
-async function handleWaitForUrl(
- command: WaitForUrlCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.waitForURL(command.url, { timeout: command.timeout });
- return successResponse(command.id, { url: page.url() });
-}
-
-async function handleWaitForLoadState(
- command: WaitForLoadStateCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.waitForLoadState(command.state, { timeout: command.timeout });
- return successResponse(command.id, { state: command.state });
-}
-
-async function handleSetContent(
- command: SetContentCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.setContent(command.html);
- return successResponse(command.id, { set: true });
-}
-
-async function handleTimezone(
- command: TimezoneCommand,
- browser: BrowserManager
-): Promise {
- // Timezone must be set at context level before navigation
- // This is a limitation - it sets for the current context
- const page = browser.getPage();
- await page.context().setGeolocation({ latitude: 0, longitude: 0 }); // Trigger context awareness
- return successResponse(command.id, {
- note: 'Timezone must be set at browser launch. Use --timezone flag.',
- timezone: command.timezone,
- });
-}
-
-async function handleLocale(command: LocaleCommand, browser: BrowserManager): Promise {
- // Locale must be set at context creation
- return successResponse(command.id, {
- note: 'Locale must be set at browser launch. Use --locale flag.',
- locale: command.locale,
- });
-}
-
-async function handleCredentials(
- command: HttpCredentialsCommand,
- browser: BrowserManager
-): Promise {
- const context = browser.getPage().context();
- await context.setHTTPCredentials({
- username: command.username,
- password: command.password,
- });
- return successResponse(command.id, { set: true });
-}
-
-async function handleMouseMove(
- command: MouseMoveCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.mouse.move(command.x, command.y);
- return successResponse(command.id, { moved: true, x: command.x, y: command.y });
-}
-
-async function handleMouseDown(
- command: MouseDownCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.mouse.down({ button: command.button ?? 'left' });
- return successResponse(command.id, { down: true });
-}
-
-async function handleMouseUp(command: MouseUpCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
- await page.mouse.up({ button: command.button ?? 'left' });
- return successResponse(command.id, { up: true });
-}
-
-async function handleBringToFront(
- command: Command & { action: 'bringtofront' },
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.bringToFront();
- return successResponse(command.id, { focused: true });
-}
-
-async function handleWaitForFunction(
- command: WaitForFunctionCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.waitForFunction(command.expression, { timeout: command.timeout });
- return successResponse(command.id, { waited: true });
-}
-
-async function handleScrollIntoView(
- command: ScrollIntoViewCommand,
- browser: BrowserManager
-): Promise {
- await browser.getLocator(command.selector).scrollIntoViewIfNeeded();
- return successResponse(command.id, { scrolled: true });
-}
-
-async function handleAddInitScript(
- command: AddInitScriptCommand,
- browser: BrowserManager
-): Promise {
- const context = browser.getPage().context();
- await context.addInitScript(command.script);
- return successResponse(command.id, { added: true });
-}
-
-async function handleKeyDown(command: KeyDownCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
- await page.keyboard.down(command.key);
- return successResponse(command.id, { down: true, key: command.key });
-}
-
-async function handleKeyUp(command: KeyUpCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
- await page.keyboard.up(command.key);
- return successResponse(command.id, { up: true, key: command.key });
-}
-
-async function handleInsertText(
- command: InsertTextCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- await page.keyboard.insertText(command.text);
- return successResponse(command.id, { inserted: true });
-}
-
-async function handleMultiSelect(
- command: MultiSelectCommand,
- browser: BrowserManager
-): Promise {
- const locator = browser.getLocator(command.selector);
- const selected = await locator.selectOption(command.values);
- return successResponse(command.id, { selected });
-}
-
-async function handleWaitForDownload(
- command: WaitForDownloadCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const download = await page.waitForEvent('download', { timeout: command.timeout });
-
- let filePath: string;
- if (command.path) {
- filePath = command.path;
- await download.saveAs(filePath);
- } else {
- filePath = (await download.path()) || download.suggestedFilename();
- }
-
- return successResponse(command.id, {
- path: filePath,
- filename: download.suggestedFilename(),
- url: download.url(),
- });
-}
-
-async function handleResponseBody(
- command: ResponseBodyCommand,
- browser: BrowserManager
-): Promise {
- const page = browser.getPage();
- const response = await page.waitForResponse((resp) => resp.url().includes(command.url), {
- timeout: command.timeout,
- });
-
- const body = await response.text();
- let parsed: unknown = body;
-
- try {
- parsed = JSON.parse(body);
- } catch {
- // Keep as string if not JSON
- }
-
- return successResponse(command.id, {
- url: response.url(),
- status: response.status(),
- body: parsed,
- });
-}
-
-// Screencast and input injection handlers
-
-async function handleScreencastStart(
- command: ScreencastStartCommand,
- browser: BrowserManager
-): Promise> {
- if (!screencastFrameCallback) {
- throw new Error('Screencast frame callback not set. Start the streaming server first.');
- }
-
- await browser.startScreencast(screencastFrameCallback, {
- format: command.format,
- quality: command.quality,
- maxWidth: command.maxWidth,
- maxHeight: command.maxHeight,
- everyNthFrame: command.everyNthFrame,
- });
-
- return successResponse(command.id, {
- started: true,
- format: command.format ?? 'jpeg',
- quality: command.quality ?? 80,
- });
-}
-
-async function handleScreencastStop(
- command: ScreencastStopCommand,
- browser: BrowserManager
-): Promise> {
- await browser.stopScreencast();
- return successResponse(command.id, { stopped: true });
-}
-
-async function handleInputMouse(
- command: InputMouseCommand,
- browser: BrowserManager
-): Promise> {
- await browser.injectMouseEvent({
- type: command.type,
- x: command.x,
- y: command.y,
- button: command.button,
- clickCount: command.clickCount,
- deltaX: command.deltaX,
- deltaY: command.deltaY,
- modifiers: command.modifiers,
- });
- return successResponse(command.id, { injected: true });
-}
-
-async function handleInputKeyboard(
- command: InputKeyboardCommand,
- browser: BrowserManager
-): Promise> {
- await browser.injectKeyboardEvent({
- type: command.type,
- key: command.key,
- code: command.code,
- text: command.text,
- modifiers: command.modifiers,
- });
- return successResponse(command.id, { injected: true });
-}
-
-async function handleInputTouch(
- command: InputTouchCommand,
- browser: BrowserManager
-): Promise> {
- await browser.injectTouchEvent({
- type: command.type,
- touchPoints: command.touchPoints,
- modifiers: command.modifiers,
- });
- return successResponse(command.id, { injected: true });
-}
-
-// Recording handlers (Playwright native video recording)
-
-async function handleRecordingStart(
- command: RecordingStartCommand,
- browser: BrowserManager
-): Promise> {
- await browser.startRecording(command.path, command.url);
- return successResponse(command.id, {
- started: true,
- path: command.path,
- });
-}
-
-async function handleRecordingStop(
- command: RecordingStopCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.stopRecording();
- return successResponse(command.id, result);
-}
-
-async function handleRecordingRestart(
- command: RecordingRestartCommand,
- browser: BrowserManager
-): Promise> {
- const result = await browser.restartRecording(command.path, command.url);
- return successResponse(command.id, {
- started: true,
- path: command.path,
- previousPath: result.previousPath,
- stopped: result.stopped,
- });
-}
-
-// Diff handlers
-
-async function handleDiffSnapshot(
- command: DiffSnapshotCommand,
- browser: BrowserManager
-): Promise {
- let before: string;
-
- if (command.baseline) {
- try {
- before = fs.readFileSync(command.baseline, 'utf-8');
- } catch {
- return errorResponse(command.id, `Cannot read baseline file: ${command.baseline}`);
- }
- } else {
- before = browser.getLastSnapshot();
- if (!before) {
- return errorResponse(
- command.id,
- 'No previous snapshot in this session. Take a snapshot first, or use --baseline .'
- );
- }
- }
-
- const page = browser.getPage();
- const { tree } = await getEnhancedSnapshot(page, {
- selector: command.selector,
- compact: command.compact,
- maxDepth: command.maxDepth,
- });
-
- const after = tree || 'Empty page';
- const result = diffSnapshots(before, after);
- browser.setLastSnapshot(after);
- return successResponse(command.id, result);
-}
-
-async function handleDiffScreenshot(
- command: DiffScreenshotCommand,
- browser: BrowserManager
-): Promise {
- if (!fs.existsSync(command.baseline)) {
- return errorResponse(command.id, `Baseline file not found: ${command.baseline}`);
- }
-
- const page = browser.getPage();
- let screenshotBuffer: Buffer;
- if (command.selector) {
- const locator = browser.getLocator(command.selector);
- screenshotBuffer = await locator.screenshot({ type: 'png' });
- } else {
- screenshotBuffer = await page.screenshot({ fullPage: command.fullPage, type: 'png' });
- }
-
- const baselineBuffer = fs.readFileSync(command.baseline);
- const ext = path.extname(command.baseline).toLowerCase();
- const baselineMime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
-
- const result = await diffScreenshots(page.context(), baselineBuffer, screenshotBuffer, {
- threshold: command.threshold,
- outputPath: command.output,
- baselineMime,
- });
-
- return successResponse(command.id, result);
-}
-
-async function handleDiffUrl(command: DiffUrlCommand, browser: BrowserManager): Promise {
- const page = browser.getPage();
-
- const waitUntil = command.waitUntil ?? 'load';
- const snapshotOpts = {
- selector: command.selector,
- compact: command.compact,
- maxDepth: command.maxDepth,
- };
-
- // Capture state of url1
- await page.goto(command.url1, { waitUntil });
- const { tree: tree1 } = await getEnhancedSnapshot(page, snapshotOpts);
- const snapshot1 = tree1 || 'Empty page';
- let screenshot1: Buffer | undefined;
- if (command.screenshot) {
- screenshot1 = await page.screenshot({ fullPage: command.fullPage, type: 'png' });
- }
-
- // Capture state of url2
- await page.goto(command.url2, { waitUntil });
- const { tree: tree2 } = await getEnhancedSnapshot(page, snapshotOpts);
- const snapshot2 = tree2 || 'Empty page';
-
- const snapshotDiff = diffSnapshots(snapshot1, snapshot2);
-
- const result: DiffUrlData = { snapshot: snapshotDiff };
-
- if (command.screenshot && screenshot1) {
- const screenshot2 = await page.screenshot({ fullPage: command.fullPage, type: 'png' });
- result.screenshot = await diffScreenshots(page.context(), screenshot1, screenshot2, {});
- }
-
- return successResponse(command.id, result);
-}
-
-async function handleAuthLogin(
- command: AuthLoginCommand,
- browser: BrowserManager
-): Promise {
- const profile = getAuthProfile(command.name);
- if (!profile) {
- return errorResponse(command.id, `Auth profile '${command.name}' not found`);
- }
-
- browser.checkDomainAllowed(profile.url);
-
- const page = browser.getPage();
- await page.goto(profile.url, { waitUntil: 'load' });
-
- const usingAutoDetect =
- !profile.usernameSelector && !profile.passwordSelector && !profile.submitSelector;
- if (usingAutoDetect) {
- console.error(
- `[agent-browser] Auth login '${command.name}': using auto-detected form selectors. ` +
- `If login fails, specify --username-selector/--password-selector/--submit-selector with auth save.`
- );
- }
-
- const passSel = profile.passwordSelector || 'input[type="password"]:visible';
-
- // Auto-detect selectors ordered from most specific to broadest.
- // Locale-dependent text matchers (e.g. "Sign in") are intentionally
- // excluded -- they break on non-English pages.
- const AUTO_USER_SELECTORS = [
- 'input[autocomplete="username"]:visible',
- 'input[type="email"]:visible',
- 'input[name="username"]:visible',
- 'input[name="email"]:visible',
- ];
- const AUTO_SUBMIT_SELECTORS = ['button[type="submit"]:visible', 'input[type="submit"]:visible'];
-
- try {
- // Resolve username field: custom selector or sequential auto-detect
- let userLocator;
- if (profile.usernameSelector) {
- userLocator = page.locator(profile.usernameSelector).first();
- } else {
- userLocator = null;
- for (const sel of AUTO_USER_SELECTORS) {
- const loc = page.locator(sel).first();
- if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
- userLocator = loc;
- break;
- }
- }
- if (!userLocator) {
- return errorResponse(
- command.id,
- `Auth login failed for '${command.name}': could not find username field. ` +
- `Specify --username-selector with auth save.`
- );
- }
- }
-
- // Resolve submit button: custom selector or sequential auto-detect
- let submitLocator;
- if (profile.submitSelector) {
- submitLocator = page.locator(profile.submitSelector).first();
- } else {
- submitLocator = null;
- for (const sel of AUTO_SUBMIT_SELECTORS) {
- const loc = page.locator(sel).first();
- if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
- submitLocator = loc;
- break;
- }
- }
- if (!submitLocator) {
- return errorResponse(
- command.id,
- `Auth login failed for '${command.name}': could not find submit button. ` +
- `Specify --submit-selector with auth save.`
- );
- }
- }
-
- await userLocator.fill(profile.username);
- await page.locator(passSel).first().fill(profile.password);
- await submitLocator.click();
- await page.waitForLoadState('load');
- } catch (err) {
- return errorResponse(
- command.id,
- `Auth login failed for '${command.name}': ${err instanceof Error ? err.message : err}. ` +
- `Try specifying custom selectors with auth save --username-selector/--password-selector/--submit-selector`
- );
- }
-
- updateLastLogin(command.name);
-
- return successResponse(command.id, {
- loggedIn: true,
- name: command.name,
- url: page.url(),
- title: await page.title(),
- });
-}
-
-async function handleConfirm(command: ConfirmCommand, browser: BrowserManager): Promise {
- const entry = getAndRemovePending(command.confirmationId);
- if (!entry) {
- return errorResponse(command.id, `No pending confirmation with id '${command.confirmationId}'`);
- }
-
- // Re-validate the stored command through the schema to guard against
- // shape drift between when the confirmation was issued and now.
- const parseResult = parseCommand(JSON.stringify(entry.command));
- if (!parseResult.success) {
- return errorResponse(command.id, `Stored command is no longer valid: ${parseResult.error}`);
- }
- const originalCommand = parseResult.command;
-
- // Re-check deny list in case policy was updated since the confirmation was issued
- actionPolicy = reloadPolicyIfChanged();
- const decision = checkPolicy(originalCommand.action, actionPolicy, new Set());
- if (decision === 'deny') {
- const category = getActionCategory(originalCommand.action);
- return errorResponse(command.id, `Action denied by policy: '${category}' is not allowed`);
- }
-
- return await dispatchAction(originalCommand, browser);
-}
-
-function handleDeny(command: DenyCommand): Response {
- const entry = getAndRemovePending(command.confirmationId);
- if (!entry) {
- return errorResponse(command.id, `No pending confirmation with id '${command.confirmationId}'`);
- }
- return successResponse(command.id, { denied: true });
-}
diff --git a/src/auth-cli.ts b/src/auth-cli.ts
deleted file mode 100644
index 61f3df0..0000000
--- a/src/auth-cli.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * Standalone CLI entry point for auth vault operations that don't need a browser.
- * Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel.
- *
- * Usage: node auth-cli.js
- * Prints a JSON response to stdout and exits.
- */
-import {
- saveAuthProfile,
- getAuthProfileMeta,
- listAuthProfiles,
- deleteAuthProfile,
-} from './auth-vault.js';
-
-interface AuthCommand {
- id: string;
- action: string;
- name?: string;
- url?: string;
- username?: string;
- password?: string;
- usernameSelector?: string;
- passwordSelector?: string;
- submitSelector?: string;
-}
-
-function success(id: string, data: Record): string {
- return JSON.stringify({ success: true, id, data });
-}
-
-function error(id: string, message: string): string {
- return JSON.stringify({ success: false, id, error: message });
-}
-
-function run(): void {
- const input = process.argv[2];
- if (!input) {
- process.stderr.write('Usage: node auth-cli.js \n');
- process.exit(1);
- }
-
- let cmd: AuthCommand;
- try {
- cmd = JSON.parse(input);
- } catch {
- console.log(error('', 'Invalid JSON input'));
- process.exit(1);
- return;
- }
-
- const id = cmd.id || '';
-
- try {
- switch (cmd.action) {
- case 'auth_save': {
- if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) {
- console.log(error(id, 'Missing required fields: name, url, username, password'));
- return;
- }
- const meta = saveAuthProfile({
- name: cmd.name,
- url: cmd.url,
- username: cmd.username,
- password: cmd.password,
- usernameSelector: cmd.usernameSelector,
- passwordSelector: cmd.passwordSelector,
- submitSelector: cmd.submitSelector,
- });
- console.log(
- success(id, {
- saved: !meta.updated,
- updated: meta.updated,
- name: meta.name,
- url: meta.url,
- username: meta.username,
- })
- );
- return;
- }
- case 'auth_list': {
- const profiles = listAuthProfiles();
- console.log(success(id, { profiles }));
- return;
- }
- case 'auth_show': {
- if (!cmd.name) {
- console.log(error(id, 'Missing required field: name'));
- return;
- }
- const meta = getAuthProfileMeta(cmd.name);
- if (!meta) {
- console.log(error(id, `Auth profile '${cmd.name}' not found`));
- return;
- }
- console.log(success(id, { profile: meta }));
- return;
- }
- case 'auth_delete': {
- if (!cmd.name) {
- console.log(error(id, 'Missing required field: name'));
- return;
- }
- const deleted = deleteAuthProfile(cmd.name);
- if (!deleted) {
- console.log(error(id, `Auth profile '${cmd.name}' not found`));
- return;
- }
- console.log(success(id, { deleted: true, name: cmd.name }));
- return;
- }
- default:
- console.log(error(id, `Unknown auth action: ${cmd.action}`));
- }
- } catch (err) {
- const msg = err instanceof Error ? err.message : 'Operation failed';
- console.log(error(id, msg));
- }
-}
-
-run();
diff --git a/src/auth-vault.test.ts b/src/auth-vault.test.ts
deleted file mode 100644
index 5e298fe..0000000
--- a/src/auth-vault.test.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import * as fs from 'fs';
-import * as path from 'path';
-import * as os from 'os';
-
-let tempHome: string;
-
-vi.mock('node:os', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- default: {
- ...actual,
- homedir: () => tempHome,
- },
- homedir: () => tempHome,
- };
-});
-
-import {
- saveAuthProfile,
- getAuthProfile,
- getAuthProfileMeta,
- listAuthProfiles,
- deleteAuthProfile,
- updateLastLogin,
-} from './auth-vault.js';
-
-describe('auth-vault', () => {
- beforeEach(() => {
- tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-'));
- delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
- });
-
- afterEach(() => {
- try {
- fs.rmSync(tempHome, { recursive: true, force: true });
- } catch {
- // ignore cleanup errors
- }
- });
-
- function cleanAuthDir() {
- const authDir = path.join(tempHome, '.agent-browser', 'auth');
- if (fs.existsSync(authDir)) {
- for (const f of fs.readdirSync(authDir)) {
- fs.unlinkSync(path.join(authDir, f));
- }
- }
- }
-
- describe('saveAuthProfile', () => {
- it('should save a new profile', () => {
- const result = saveAuthProfile({
- name: 'github',
- url: 'https://github.com/login',
- username: 'user',
- password: 'pass',
- });
-
- expect(result.name).toBe('github');
- expect(result.url).toBe('https://github.com/login');
- expect(result.username).toBe('user');
- expect(result.updated).toBe(false);
- expect(result.createdAt).toBeTruthy();
- });
-
- it('should mark as updated when overwriting', () => {
- saveAuthProfile({
- name: 'github',
- url: 'https://github.com/login',
- username: 'user1',
- password: 'pass1',
- });
-
- const result = saveAuthProfile({
- name: 'github',
- url: 'https://github.com/login',
- username: 'user2',
- password: 'pass2',
- });
-
- expect(result.updated).toBe(true);
- expect(result.username).toBe('user2');
- });
-
- it('should preserve createdAt on update', () => {
- const first = saveAuthProfile({
- name: 'github',
- url: 'https://github.com/login',
- username: 'user',
- password: 'pass',
- });
-
- const second = saveAuthProfile({
- name: 'github',
- url: 'https://github.com/login',
- username: 'user2',
- password: 'pass2',
- });
-
- expect(second.createdAt).toBe(first.createdAt);
- });
-
- it('should save with custom selectors', () => {
- saveAuthProfile({
- name: 'myapp',
- url: 'https://example.com/login',
- username: 'user',
- password: 'pass',
- usernameSelector: '#email',
- passwordSelector: '#password',
- submitSelector: 'button.login',
- });
-
- const profile = getAuthProfile('myapp');
- expect(profile).not.toBeNull();
- expect(profile!.usernameSelector).toBe('#email');
- expect(profile!.passwordSelector).toBe('#password');
- expect(profile!.submitSelector).toBe('button.login');
- });
-
- it('should reject invalid profile names', () => {
- expect(() =>
- saveAuthProfile({
- name: '../escape',
- url: 'https://example.com',
- username: 'user',
- password: 'pass',
- })
- ).toThrow('only alphanumeric');
- });
- });
-
- describe('getAuthProfile', () => {
- it('should return null for non-existent profile', () => {
- expect(getAuthProfile('nonexistent')).toBeNull();
- });
-
- it('should return full profile with password', () => {
- saveAuthProfile({
- name: 'test',
- url: 'https://example.com',
- username: 'user',
- password: 'secret',
- });
-
- const profile = getAuthProfile('test');
- expect(profile).not.toBeNull();
- expect(profile!.password).toBe('secret');
- });
- });
-
- describe('getAuthProfileMeta', () => {
- it('should return metadata without password', () => {
- saveAuthProfile({
- name: 'test',
- url: 'https://example.com',
- username: 'user',
- password: 'secret',
- });
-
- const meta = getAuthProfileMeta('test');
- expect(meta).not.toBeNull();
- expect(meta!.name).toBe('test');
- expect(meta!.username).toBe('user');
- expect((meta as Record).password).toBeUndefined();
- });
-
- it('should return null for non-existent profile', () => {
- expect(getAuthProfileMeta('nonexistent')).toBeNull();
- });
- });
-
- describe('listAuthProfiles', () => {
- it('should return empty array when no profiles', () => {
- cleanAuthDir();
- expect(listAuthProfiles()).toEqual([]);
- });
-
- it('should list all saved profiles', () => {
- cleanAuthDir();
- saveAuthProfile({
- name: 'github',
- url: 'https://github.com/login',
- username: 'user1',
- password: 'pass1',
- });
- saveAuthProfile({
- name: 'gitlab',
- url: 'https://gitlab.com/login',
- username: 'user2',
- password: 'pass2',
- });
-
- const profiles = listAuthProfiles();
- expect(profiles).toHaveLength(2);
- const names = profiles.map((p) => p.name).sort();
- expect(names).toEqual(['github', 'gitlab']);
- });
- });
-
- describe('deleteAuthProfile', () => {
- it('should delete an existing profile', () => {
- saveAuthProfile({
- name: 'test',
- url: 'https://example.com',
- username: 'user',
- password: 'pass',
- });
-
- expect(deleteAuthProfile('test')).toBe(true);
- expect(getAuthProfile('test')).toBeNull();
- });
-
- it('should return false for non-existent profile', () => {
- expect(deleteAuthProfile('nonexistent')).toBe(false);
- });
- });
-
- describe('updateLastLogin', () => {
- it('should update lastLoginAt timestamp', () => {
- saveAuthProfile({
- name: 'test',
- url: 'https://example.com',
- username: 'user',
- password: 'pass',
- });
-
- const metaBefore = getAuthProfileMeta('test');
- expect(metaBefore!.lastLoginAt).toBeUndefined();
-
- updateLastLogin('test');
-
- const metaAfter = getAuthProfileMeta('test');
- expect(metaAfter!.lastLoginAt).toBeTruthy();
- });
- });
-
- describe('auto-generated encryption key', () => {
- it('should auto-create key file and encrypt profile when no env var is set', () => {
- delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
-
- saveAuthProfile({
- name: 'autokey',
- url: 'https://example.com',
- username: 'user',
- password: 'secret',
- });
-
- const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key');
- expect(fs.existsSync(keyFilePath)).toBe(true);
-
- const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim();
- expect(keyHex).toMatch(/^[a-f0-9]{64}$/);
-
- const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json');
- const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
- expect(raw.encrypted).toBe(true);
- expect(raw.iv).toBeTruthy();
- });
-
- it('should read back profile using auto-generated key', () => {
- delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
-
- saveAuthProfile({
- name: 'readback',
- url: 'https://example.com',
- username: 'user',
- password: 'secret123',
- });
-
- const profile = getAuthProfile('readback');
- expect(profile).not.toBeNull();
- expect(profile!.password).toBe('secret123');
- });
- });
-});
diff --git a/src/auth-vault.ts b/src/auth-vault.ts
deleted file mode 100644
index 577b4fd..0000000
--- a/src/auth-vault.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import {
- existsSync,
- mkdirSync,
- readFileSync,
- writeFileSync,
- readdirSync,
- unlinkSync,
-} from 'node:fs';
-import path from 'node:path';
-import os from 'node:os';
-import {
- getEncryptionKey,
- ensureEncryptionKey,
- encryptData,
- decryptData,
- isEncryptedPayload,
- getKeyFilePath,
- restrictFilePermissions,
- restrictDirPermissions,
- type EncryptedPayload,
-} from './encryption.js';
-
-const AUTH_DIR = 'auth';
-
-interface AuthProfile {
- name: string;
- url: string;
- username: string;
- password: string;
- usernameSelector?: string;
- passwordSelector?: string;
- submitSelector?: string;
- createdAt: string;
- lastLoginAt?: string;
-}
-
-export interface AuthProfileMeta {
- name: string;
- url: string;
- username: string;
- createdAt: string;
- lastLoginAt?: string;
-}
-
-function getAuthDir(): string {
- const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR);
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- restrictDirPermissions(dir);
- }
- return dir;
-}
-
-const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
-
-function validateProfileName(name: string): void {
- if (!SAFE_NAME_RE.test(name)) {
- throw new Error(
- `Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed`
- );
- }
-}
-
-function profilePath(name: string): string {
- validateProfileName(name);
- return path.join(getAuthDir(), `${name}.json`);
-}
-
-function readProfile(name: string): AuthProfile | null {
- const p = profilePath(name);
- if (!existsSync(p)) return null;
-
- const raw = readFileSync(p, 'utf-8');
- const parsed = JSON.parse(raw);
-
- if (isEncryptedPayload(parsed)) {
- const key = getEncryptionKey();
- if (!key) {
- throw new Error(
- `Encryption key required to read encrypted auth profiles. ` +
- `Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.`
- );
- }
- const decrypted = decryptData(parsed as EncryptedPayload, key);
- return JSON.parse(decrypted) as AuthProfile;
- }
-
- return parsed as AuthProfile;
-}
-
-function writeProfile(profile: AuthProfile): void {
- const key = ensureEncryptionKey();
- const serialized = JSON.stringify(profile, null, 2);
- const encrypted = encryptData(serialized, key);
- const filePath = profilePath(profile.name);
- writeFileSync(filePath, JSON.stringify(encrypted, null, 2), {
- mode: 0o600,
- });
- restrictFilePermissions(filePath);
-}
-
-export function saveAuthProfile(opts: {
- name: string;
- url: string;
- username: string;
- password: string;
- usernameSelector?: string;
- passwordSelector?: string;
- submitSelector?: string;
-}): AuthProfileMeta & { updated: boolean } {
- const existing = readProfile(opts.name);
-
- const profile: AuthProfile = {
- name: opts.name,
- url: opts.url,
- username: opts.username,
- password: opts.password,
- usernameSelector: opts.usernameSelector,
- passwordSelector: opts.passwordSelector,
- submitSelector: opts.submitSelector,
- createdAt: existing?.createdAt ?? new Date().toISOString(),
- lastLoginAt: existing?.lastLoginAt,
- };
-
- writeProfile(profile);
-
- return {
- name: profile.name,
- url: profile.url,
- username: profile.username,
- createdAt: profile.createdAt,
- lastLoginAt: profile.lastLoginAt,
- updated: existing !== null,
- };
-}
-
-export function getAuthProfile(name: string): AuthProfile | null {
- return readProfile(name);
-}
-
-export function getAuthProfileMeta(name: string): AuthProfileMeta | null {
- const profile = readProfile(name);
- if (!profile) return null;
- return {
- name: profile.name,
- url: profile.url,
- username: profile.username,
- createdAt: profile.createdAt,
- lastLoginAt: profile.lastLoginAt,
- };
-}
-
-export function listAuthProfiles(): AuthProfileMeta[] {
- const dir = getAuthDir();
- const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
- const profiles: AuthProfileMeta[] = [];
-
- for (const file of files) {
- const name = file.replace(/\.json$/, '');
- try {
- const meta = getAuthProfileMeta(name);
- if (meta) profiles.push(meta);
- } catch {
- profiles.push({
- name,
- url: '(encrypted)',
- username: '(encrypted)',
- createdAt: '(unknown)',
- });
- }
- }
-
- return profiles;
-}
-
-export function deleteAuthProfile(name: string): boolean {
- const p = profilePath(name);
- if (!existsSync(p)) return false;
- unlinkSync(p);
- return true;
-}
-
-export function updateLastLogin(name: string): void {
- const profile = readProfile(name);
- if (profile) {
- profile.lastLoginAt = new Date().toISOString();
- writeProfile(profile);
- }
-}
diff --git a/src/browser.test.ts b/src/browser.test.ts
deleted file mode 100644
index 3007cb6..0000000
--- a/src/browser.test.ts
+++ /dev/null
@@ -1,1414 +0,0 @@
-import {
- describe,
- it,
- expect,
- expectTypeOf,
- beforeAll,
- afterAll,
- beforeEach,
- afterEach,
- vi,
-} from 'vitest';
-import { BrowserManager, getDefaultTimeout } from './browser.js';
-import type { BrowserManager as PublicBrowserManager, BrowserLaunchOptions } from './index.js';
-import { executeCommand } from './actions.js';
-import { chromium } from 'playwright-core';
-import os from 'node:os';
-import path from 'node:path';
-import { existsSync, rmSync } from 'node:fs';
-
-describe('BrowserManager', () => {
- let browser: BrowserManager;
-
- beforeAll(async () => {
- browser = new BrowserManager();
- await browser.launch({ headless: true });
- });
-
- afterAll(async () => {
- await browser.close();
- });
-
- describe('launch and close', () => {
- it('should report as launched', () => {
- expect(browser.isLaunched()).toBe(true);
- });
-
- it('should have a page', () => {
- const page = browser.getPage();
- expect(page).toBeDefined();
- });
-
- it('should reject invalid executablePath', async () => {
- const testBrowser = new BrowserManager();
- await expect(
- testBrowser.launch({
- headless: true,
- executablePath: '/nonexistent/path/to/chromium',
- })
- ).rejects.toThrow();
- });
-
- it('should be no-op when relaunching with same options', async () => {
- const browserInstance = browser.getBrowser();
- await browser.launch({ headless: true });
- expect(browser.getBrowser()).toBe(browserInstance);
- });
-
- it('should reconnect when CDP port changes', async () => {
- const newBrowser = new BrowserManager();
- await newBrowser.launch({ headless: true });
- expect(newBrowser.getBrowser()).not.toBeNull();
-
- await expect(newBrowser.launch({ cdpPort: 59999 })).rejects.toThrow();
-
- expect(newBrowser.getBrowser()).toBeNull();
- await newBrowser.close();
- });
- });
-
- describe('stale session recovery (all pages closed)', () => {
- it('should recover when all pages are closed externally', async () => {
- const testBrowser = new BrowserManager();
- await testBrowser.launch({ headless: true });
-
- // Verify initial state
- expect(testBrowser.isLaunched()).toBe(true);
- expect(testBrowser.getPage()).toBeDefined();
-
- // Close all pages externally (simulates stale daemon state)
- const pages = testBrowser.getPages();
- for (const page of [...pages]) {
- await page.close();
- }
-
- // Wait for close events to propagate
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- // isLaunched() is true but pages array is empty -- this is the stale state
- expect(testBrowser.isLaunched()).toBe(true);
- expect(testBrowser.getPages().length).toBe(0);
-
- // ensurePage() should recover by creating a new page
- await testBrowser.ensurePage();
- expect(testBrowser.getPages().length).toBe(1);
- expect(testBrowser.getPage()).toBeDefined();
-
- await testBrowser.close();
- });
-
- it('should be a no-op when pages already exist', async () => {
- const testBrowser = new BrowserManager();
- await testBrowser.launch({ headless: true });
-
- const pageBefore = testBrowser.getPage();
- await testBrowser.ensurePage();
- const pageAfter = testBrowser.getPage();
-
- // Should be the same page -- no-op
- expect(pageAfter).toBe(pageBefore);
- expect(testBrowser.getPages().length).toBe(1);
-
- await testBrowser.close();
- });
- });
-
- describe('scrollintoview with refs', () => {
- it('should resolve refs in scrollintoview command', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
-
- Far Away Button
-
-
- `);
-
- // Get snapshot to populate refs
- const { refs } = await browser.getSnapshot({ interactive: true });
-
- // Find the ref for our button
- const buttonRef = Object.keys(refs).find((k) => refs[k].name === 'Far Away Button');
- expect(buttonRef).toBeDefined();
-
- // scrollintoview with a ref should work, not throw a CSS selector error
- const result = await executeCommand(
- { id: 'test-1', action: 'scrollintoview', selector: `@${buttonRef}` },
- browser
- );
- expect(result.success).toBe(true);
- });
-
- it('should resolve refs in scroll command with selector', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
-
- Target Button
-
-
- `);
-
- const { refs } = await browser.getSnapshot({ interactive: true });
- const buttonRef = Object.keys(refs).find((k) => refs[k].name === 'Target Button');
- expect(buttonRef).toBeDefined();
-
- // scroll with a ref selector should work
- const result = await executeCommand(
- { id: 'test-2', action: 'scroll', selector: `@${buttonRef}`, y: 100 },
- browser
- );
- expect(result.success).toBe(true);
- });
- });
-
- describe('unnamed-button ref uniqueness', () => {
- it('should click the correct unnamed button among named buttons', async () => {
- const page = browser.getPage();
- // 1 unnamed button among 2 named buttons
- await page.setContent(`
-
- OK
-
- Cancel
-
- `);
-
- const snapshot = await browser.getSnapshot();
- const refs = snapshot.refs;
- const unnamedRefs = Object.entries(refs).filter(([, v]) => v.role === 'button' && !v.name);
- expect(unnamedRefs.length).toBe(1);
-
- const [refId] = unnamedRefs[0];
- await executeCommand({ id: 'test', action: 'click', selector: `@${refId}` }, browser);
- const title = await page.title();
- expect(title).toBe('unnamed');
- });
- });
-
- describe('cursor-ref selector uniqueness', () => {
- it('should produce unique selectors for repeated DOM structures', async () => {
- const page = browser.getPage();
- // Build deeply nested identical structures where the distinguishing
- // ancestor (div.branch) is at level 4 from the target element --
- // beyond the previous 3-level path cutoff.
- await page.setContent(`
-
-
-
-
-
- `);
-
- const { refs } = await browser.getSnapshot({ interactive: true, cursor: true });
-
- // Find the cursor-interactive refs
- const cursorRefs = Object.entries(refs).filter(([, r]) => r.role === 'clickable');
- expect(cursorRefs.length).toBe(2);
-
- // Each ref's selector must be unique -- clicking it should not
- // trigger a strict mode violation.
- for (const [refKey] of cursorRefs) {
- const locator = browser.getLocator(`@${refKey}`);
- const count = await locator.count();
- expect(count).toBe(1);
- }
- });
-
- it('should click the correct element when refs have repeated structure', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
-
- none
-
-
- `);
-
- const { refs } = await browser.getSnapshot({ interactive: true, cursor: true });
-
- // Find the ref for "Item Beta"
- const betaRef = Object.keys(refs).find((k) => refs[k].name === 'Item Beta');
- expect(betaRef).toBeDefined();
-
- // Click it -- should not throw strict mode violation
- const locator = browser.getLocator(`@${betaRef}`);
- await locator.click();
-
- const result = await page.locator('#result').textContent();
- expect(result).toBe('beta');
- });
- });
-
- describe('navigation', () => {
- it('should navigate to URL', async () => {
- const page = browser.getPage();
- await page.goto('https://example.com');
- expect(page.url()).toBe('https://example.com/');
- });
-
- it('should navigate via BrowserManager API', async () => {
- const result = await browser.navigate('https://example.com');
- expect(result).toEqual({
- url: 'https://example.com/',
- title: 'Example Domain',
- });
- });
-
- it('should navigate with custom headers without throwing', async () => {
- const result = await browser.navigate('https://example.com', {
- headers: { 'X-Custom-Header': 'test-value' },
- });
- expect(result.url).toBe('https://example.com/');
- expect(result.title).toBe('Example Domain');
- });
-
- it('should navigate with waitUntil option', async () => {
- const result = await browser.navigate('https://example.com', {
- waitUntil: 'domcontentloaded',
- });
- expect(result.url).toBe('https://example.com/');
- });
-
- it('should get page title', async () => {
- const page = browser.getPage();
- const title = await page.title();
- expect(title).toBe('Example Domain');
- });
-
- it('should expose current URL and title via BrowserManager API', async () => {
- await browser.navigate('https://example.com');
- await expect(browser.getUrl()).resolves.toBe('https://example.com/');
- await expect(browser.getTitle()).resolves.toBe('Example Domain');
- });
- });
-
- describe('navigate() domain filtering', () => {
- it('should block navigation to a domain outside allowedDomains', async () => {
- const restricted = new BrowserManager();
- await restricted.launch({ headless: true, allowedDomains: ['example.com'] });
- try {
- await expect(restricted.navigate('https://httpbin.org')).rejects.toThrow(
- 'Navigation blocked'
- );
- } finally {
- await restricted.close();
- }
- });
-
- it('should allow navigation within allowedDomains', async () => {
- const restricted = new BrowserManager();
- await restricted.launch({ headless: true, allowedDomains: ['example.com'] });
- try {
- const result = await restricted.navigate('https://example.com');
- expect(result.url).toBe('https://example.com/');
- } finally {
- await restricted.close();
- }
- });
-
- it('should block non-http(s) schemes regardless of allowedDomains', async () => {
- const restricted = new BrowserManager();
- await restricted.launch({ headless: true, allowedDomains: ['example.com'] });
- try {
- await expect(restricted.navigate('ftp://example.com')).rejects.toThrow(
- 'Navigation blocked'
- );
- } finally {
- await restricted.close();
- }
- });
- });
-
- describe('navigate() public API types (issue #307)', () => {
- it('BrowserManager exported from package entry has navigate method', () => {
- // Compile-time proof: if this file type-checks, the public API surface is correct.
- // navigate() must exist on BrowserManager — absence was the bug in #307.
- expectTypeOf>().toHaveProperty('navigate');
- expectTypeOf>().toHaveProperty('launch');
- });
-
- it('BrowserLaunchOptions does not require id or action', () => {
- // id and action are IPC-only fields that must not leak into the public API
- expectTypeOf().not.toHaveProperty('id');
- expectTypeOf().not.toHaveProperty('action');
- expectTypeOf().not.toHaveProperty('engine');
- });
- });
-
- describe('element interaction', () => {
- it('should find element by selector', async () => {
- const page = browser.getPage();
- const heading = await page.locator('h1').textContent();
- expect(heading).toBe('Example Domain');
- });
-
- it('should check element visibility', async () => {
- const page = browser.getPage();
- const isVisible = await page.locator('h1').isVisible();
- expect(isVisible).toBe(true);
- });
-
- it('should count elements', async () => {
- const page = browser.getPage();
- const count = await page.locator('p').count();
- expect(count).toBeGreaterThan(0);
- });
- });
-
- describe('screenshots', () => {
- it('should take screenshot as buffer', async () => {
- const page = browser.getPage();
- const buffer = await page.screenshot();
- expect(buffer).toBeInstanceOf(Buffer);
- expect(buffer.length).toBeGreaterThan(0);
- });
- });
-
- describe('annotated screenshots', () => {
- afterAll(async () => {
- await browser.getPage().goto('https://example.com');
- });
-
- it('should return annotations with correct shape', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
- Submit
- Home
-
-
- `);
-
- const result = await executeCommand(
- { id: 'ann-1', action: 'screenshot', annotate: true },
- browser
- );
-
- expect(result.success).toBe(true);
- const data = result.data as { path?: string; annotations?: unknown[] };
- expect(data.path).toBeDefined();
- expect(data.annotations).toBeDefined();
- expect(data.annotations!.length).toBeGreaterThan(0);
-
- for (const ann of data.annotations! as Array<{
- ref: string;
- number: number;
- role: string;
- name?: string;
- box: { x: number; y: number; width: number; height: number };
- }>) {
- expect(ann.ref).toMatch(/^e\d+$/);
- expect(typeof ann.number).toBe('number');
- expect(typeof ann.role).toBe('string');
- expect(typeof ann.box.x).toBe('number');
- expect(typeof ann.box.y).toBe('number');
- expect(typeof ann.box.width).toBe('number');
- expect(typeof ann.box.height).toBe('number');
- }
- });
-
- it('should clean up overlay from DOM after screenshot', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
- Click me
-
- `);
-
- await executeCommand({ id: 'ann-2', action: 'screenshot', annotate: true }, browser);
-
- const overlay = await page.$('#__agent_browser_annotations__');
- expect(overlay).toBeNull();
- });
-
- it('should scope annotations to selector element', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
- Outside
-
- Inside
-
-
- `);
-
- const result = await executeCommand(
- { id: 'ann-3', action: 'screenshot', annotate: true, selector: '#container' },
- browser
- );
-
- expect(result.success).toBe(true);
- const data = result.data as { annotations?: Array<{ name?: string }> };
- expect(data.annotations).toBeDefined();
-
- const names = data.annotations!.map((a) => a.name).filter(Boolean);
- expect(names).toContain('Inside');
- expect(names).not.toContain('Outside');
- });
-
- it('should succeed with no annotations on static page', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
- Just some text, no interactive elements.
-
- `);
-
- const result = await executeCommand(
- { id: 'ann-4', action: 'screenshot', annotate: true },
- browser
- );
-
- expect(result.success).toBe(true);
- const data = result.data as { path?: string; annotations?: unknown[] };
- expect(data.path).toBeDefined();
- expect(data.annotations).toBeUndefined();
- });
-
- it('should return document-relative coords for fullPage screenshots', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
- Bottom
-
- `);
-
- const result = await executeCommand(
- { id: 'ann-5', action: 'screenshot', annotate: true, fullPage: true },
- browser
- );
-
- expect(result.success).toBe(true);
- const data = result.data as {
- annotations?: Array<{ name?: string; box: { y: number } }>;
- };
- expect(data.annotations).toBeDefined();
-
- const bottom = data.annotations!.find((a) => a.name === 'Bottom');
- expect(bottom).toBeDefined();
- expect(bottom!.box.y).toBeGreaterThanOrEqual(2000);
- });
- });
-
- describe('evaluate', () => {
- it('should evaluate JavaScript', async () => {
- const page = browser.getPage();
- const result = await page.evaluate(() => document.title);
- expect(result).toBe('Example Domain');
- });
-
- it('should evaluate with arguments', async () => {
- const page = browser.getPage();
- const result = await page.evaluate((x: number) => x * 2, 5);
- expect(result).toBe(10);
- });
- });
-
- describe('tabs', () => {
- it('should create new tab', async () => {
- const result = await browser.newTab();
- expect(result.index).toBe(1);
- expect(result.total).toBe(2);
- });
-
- it('should list tabs', async () => {
- const tabs = await browser.listTabs();
- expect(tabs.length).toBe(2);
- });
-
- it('should close tab', async () => {
- // Switch to second tab and close it
- const page = browser.getPage();
- const tabs = await browser.listTabs();
- if (tabs.length > 1) {
- const result = await browser.closeTab(1);
- expect(result.remaining).toBe(1);
- }
- });
-
- it('should auto-switch to externally opened tab (window.open)', async () => {
- // Ensure we start on tab 0
- const initialIndex = browser.getActiveIndex();
- expect(initialIndex).toBe(0);
-
- const page = browser.getPage();
-
- // Use window.open to create a new tab externally (as a user/script would)
- await page.evaluate(() => {
- window.open('about:blank', '_blank');
- });
-
- // Wait for the new page event to be processed
- await new Promise((resolve) => setTimeout(resolve, 500));
-
- // Active tab should now be the newly opened tab
- const newIndex = browser.getActiveIndex();
- expect(newIndex).toBe(1);
-
- const tabs = await browser.listTabs();
- expect(tabs.length).toBe(2);
- expect(tabs[1].active).toBe(true);
-
- // Clean up: close the new tab
- await browser.closeTab(1);
- });
- });
-
- describe('context operations', () => {
- it('should get cookies from context', async () => {
- const page = browser.getPage();
- const cookies = await page.context().cookies();
- expect(Array.isArray(cookies)).toBe(true);
- });
-
- it('should set and get cookies', async () => {
- const page = browser.getPage();
- const context = page.context();
- await context.addCookies([{ name: 'test', value: 'value', url: 'https://example.com' }]);
- const cookies = await context.cookies();
- const testCookie = cookies.find((c) => c.name === 'test');
- expect(testCookie?.value).toBe('value');
- });
-
- it('should set cookie with domain', async () => {
- const page = browser.getPage();
- const context = page.context();
- await context.addCookies([
- { name: 'domainCookie', value: 'domainValue', domain: 'example.com', path: '/' },
- ]);
- const cookies = await context.cookies();
- const testCookie = cookies.find((c) => c.name === 'domainCookie');
- expect(testCookie?.value).toBe('domainValue');
- });
-
- it('should set multiple cookies at once', async () => {
- const page = browser.getPage();
- const context = page.context();
- await context.clearCookies();
- await context.addCookies([
- { name: 'cookie1', value: 'value1', url: 'https://example.com' },
- { name: 'cookie2', value: 'value2', url: 'https://example.com' },
- ]);
- const cookies = await context.cookies();
- expect(cookies.find((c) => c.name === 'cookie1')?.value).toBe('value1');
- expect(cookies.find((c) => c.name === 'cookie2')?.value).toBe('value2');
- });
-
- it('should clear cookies', async () => {
- const page = browser.getPage();
- const context = page.context();
- await context.clearCookies();
- const cookies = await context.cookies();
- expect(cookies.length).toBe(0);
- });
- });
-
- describe('localStorage operations', () => {
- it('should set and get localStorage item', async () => {
- const page = browser.getPage();
- await page.goto('https://example.com');
- await page.evaluate(() => localStorage.setItem('testKey', 'testValue'));
- const value = await page.evaluate(() => localStorage.getItem('testKey'));
- expect(value).toBe('testValue');
- });
-
- it('should get all localStorage items', async () => {
- const page = browser.getPage();
- await page.evaluate(() => {
- localStorage.clear();
- localStorage.setItem('key1', 'value1');
- localStorage.setItem('key2', 'value2');
- });
- const storage = await page.evaluate(() => {
- const items: Record = {};
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- if (key) items[key] = localStorage.getItem(key) || '';
- }
- return items;
- });
- expect(storage.key1).toBe('value1');
- expect(storage.key2).toBe('value2');
- });
-
- it('should clear localStorage', async () => {
- const page = browser.getPage();
- await page.evaluate(() => localStorage.clear());
- const value = await page.evaluate(() => localStorage.getItem('testKey'));
- expect(value).toBeNull();
- });
-
- it('should return null for non-existent key', async () => {
- const page = browser.getPage();
- await page.evaluate(() => localStorage.clear());
- const value = await page.evaluate(() => localStorage.getItem('nonexistent'));
- expect(value).toBeNull();
- });
- });
-
- describe('sessionStorage operations', () => {
- it('should set and get sessionStorage item', async () => {
- const page = browser.getPage();
- await page.goto('https://example.com');
- await page.evaluate(() => sessionStorage.setItem('sessionKey', 'sessionValue'));
- const value = await page.evaluate(() => sessionStorage.getItem('sessionKey'));
- expect(value).toBe('sessionValue');
- });
-
- it('should get all sessionStorage items', async () => {
- const page = browser.getPage();
- await page.evaluate(() => {
- sessionStorage.clear();
- sessionStorage.setItem('skey1', 'svalue1');
- sessionStorage.setItem('skey2', 'svalue2');
- });
- const storage = await page.evaluate(() => {
- const items: Record = {};
- for (let i = 0; i < sessionStorage.length; i++) {
- const key = sessionStorage.key(i);
- if (key) items[key] = sessionStorage.getItem(key) || '';
- }
- return items;
- });
- expect(storage.skey1).toBe('svalue1');
- expect(storage.skey2).toBe('svalue2');
- });
-
- it('should clear sessionStorage', async () => {
- const page = browser.getPage();
- await page.evaluate(() => sessionStorage.clear());
- const value = await page.evaluate(() => sessionStorage.getItem('sessionKey'));
- expect(value).toBeNull();
- });
- });
-
- describe('viewport', () => {
- it('should set viewport', async () => {
- await browser.setViewport(1920, 1080);
- const page = browser.getPage();
- const size = page.viewportSize();
- expect(size?.width).toBe(1920);
- expect(size?.height).toBe(1080);
- });
-
- it('should inherit the current viewport when starting a recording', async () => {
- const recordingPath = path.join(os.tmpdir(), `agent-browser-recording-${Date.now()}.webm`);
-
- await browser.setViewport(440, 956);
-
- try {
- await browser.startRecording(recordingPath);
- const recordingPage = (browser as any).recordingPage;
- expect(recordingPage.viewportSize()).toEqual({ width: 440, height: 956 });
- } finally {
- if (browser.isRecording()) {
- await browser.stopRecording();
- }
- if (existsSync(recordingPath)) {
- rmSync(recordingPath, { force: true });
- }
- }
- });
-
- it('should disable viewport when --start-maximized is in args', async () => {
- const testBrowser = new BrowserManager();
- await testBrowser.launch({ headless: true, args: ['--start-maximized'] });
- const page = testBrowser.getPage();
- expect(page.viewportSize()).toBeNull();
- await testBrowser.close();
- });
-
- it('should disable viewport when --window-size is in args', async () => {
- const testBrowser = new BrowserManager();
- await testBrowser.launch({ headless: true, args: ['--window-size=800,600'] });
- const page = testBrowser.getPage();
- expect(page.viewportSize()).toBeNull();
- await testBrowser.close();
- });
-
- it('should use default viewport when no window size args', async () => {
- const testBrowser = new BrowserManager();
- await testBrowser.launch({ headless: true });
- const page = testBrowser.getPage();
- expect(page.viewportSize()).toEqual({ width: 1280, height: 720 });
- await testBrowser.close();
- });
-
- it('should use explicit viewport even with --start-maximized', async () => {
- const testBrowser = new BrowserManager();
- await testBrowser.launch({
- headless: true,
- args: ['--start-maximized'],
- viewport: { width: 800, height: 600 },
- });
- const page = testBrowser.getPage();
- expect(page.viewportSize()).toEqual({ width: 800, height: 600 });
- await testBrowser.close();
- });
- });
-
- describe('snapshot', () => {
- it('should get snapshot with refs', async () => {
- const page = browser.getPage();
- await page.goto('https://example.com');
- const { tree, refs } = await browser.getSnapshot();
- expect(tree).toContain('heading');
- expect(tree).toContain('Example Domain');
- expect(typeof refs).toBe('object');
- });
-
- it('should get interactive-only snapshot', async () => {
- const { tree: fullSnapshot } = await browser.getSnapshot();
- const { tree: interactiveSnapshot } = await browser.getSnapshot({ interactive: true });
- // Interactive snapshot should be shorter (fewer elements)
- expect(interactiveSnapshot.length).toBeLessThanOrEqual(fullSnapshot.length);
- });
-
- it('should get snapshot with depth limit', async () => {
- const { tree: fullSnapshot } = await browser.getSnapshot();
- const { tree: limitedSnapshot } = await browser.getSnapshot({ maxDepth: 2 });
- // Limited depth should have fewer nested elements
- const fullLines = fullSnapshot.split('\n').length;
- const limitedLines = limitedSnapshot.split('\n').length;
- expect(limitedLines).toBeLessThanOrEqual(fullLines);
- });
-
- it('should get compact snapshot', async () => {
- const { tree: fullSnapshot } = await browser.getSnapshot();
- const { tree: compactSnapshot } = await browser.getSnapshot({ compact: true });
- // Compact should be equal or shorter
- expect(compactSnapshot.length).toBeLessThanOrEqual(fullSnapshot.length);
- });
-
- it('should not capture cursor-interactive elements without cursor flag', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
- Standard Button
- Clickable Div
-
-
- `);
-
- const { tree, refs } = await browser.getSnapshot({ interactive: true });
-
- // Standard button should be captured via ARIA
- expect(tree).toContain('button "Standard Button"');
-
- // Cursor-interactive elements should NOT be captured without cursor flag
- expect(tree).not.toContain('Cursor-interactive elements');
- expect(tree).not.toContain('clickable "Clickable Div"');
-
- // Should only have refs for ARIA interactive elements
- const refValues = Object.values(refs);
- expect(refValues.some((r) => r.role === 'button')).toBe(true);
- expect(refValues.some((r) => r.role === 'clickable')).toBe(false);
- });
-
- it('should capture cursor-interactive elements with cursor flag', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
- Standard Button
- Clickable Div
- Onclick Span
-
-
- `);
-
- const { tree, refs } = await browser.getSnapshot({ interactive: true, cursor: true });
-
- // Standard button should be captured via ARIA
- expect(tree).toContain('button "Standard Button"');
-
- // Cursor-interactive elements should be captured with cursor flag
- expect(tree).toContain('Cursor-interactive elements');
- expect(tree).toContain('clickable "Clickable Div"');
- expect(tree).toContain('clickable "Onclick Span"');
-
- // Should have refs for all interactive elements
- const refValues = Object.values(refs);
- expect(refValues.some((r) => r.role === 'button')).toBe(true);
- expect(refValues.some((r) => r.role === 'clickable')).toBe(true);
- });
-
- it('should click cursor-interactive elements via refs', async () => {
- const page = browser.getPage();
- await page.setContent(`
-
-
- Click Me
- not clicked
-
-
- `);
-
- const { refs } = await browser.getSnapshot({ cursor: true });
-
- // Find the ref for the clickable element
- const clickableRef = Object.keys(refs).find((k) => refs[k].name === 'Click Me');
- expect(clickableRef).toBeDefined();
-
- // Click using the ref
- const locator = browser.getLocator(`@${clickableRef}`);
- await locator.click();
-
- // Verify click worked
- const result = await page.locator('#result').textContent();
- expect(result).toBe('clicked');
- });
- });
-
- describe('locator resolution', () => {
- it('should resolve CSS selector', async () => {
- const page = browser.getPage();
- await page.goto('https://example.com');
- const locator = browser.getLocator('h1');
- const text = await locator.textContent();
- expect(text).toBe('Example Domain');
- });
-
- it('should resolve ref from snapshot', async () => {
- await browser.getSnapshot(); // Populates refs
- // After snapshot, refs like @e1 should be available
- // This tests the ref resolution mechanism
- const page = browser.getPage();
- const h1 = await page.locator('h1').textContent();
- expect(h1).toBe('Example Domain');
- });
- });
-
- describe('scoped headers', () => {
- it('should register route for scoped headers', async () => {
- // Test that setScopedHeaders doesn't throw and completes successfully
- await browser.clearScopedHeaders();
- await expect(
- browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' })
- ).resolves.not.toThrow();
- await browser.clearScopedHeaders();
- });
-
- it('should handle full URL origin', async () => {
- await browser.clearScopedHeaders();
- await expect(
- browser.setScopedHeaders('https://api.example.com/path', { Authorization: 'Bearer token' })
- ).resolves.not.toThrow();
- await browser.clearScopedHeaders();
- });
-
- it('should handle hostname-only origin', async () => {
- await browser.clearScopedHeaders();
- await expect(
- browser.setScopedHeaders('example.com', { 'X-Custom': 'value' })
- ).resolves.not.toThrow();
- await browser.clearScopedHeaders();
- });
-
- it('should clear scoped headers for specific origin', async () => {
- await browser.clearScopedHeaders();
- await browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' });
- await expect(browser.clearScopedHeaders('https://example.com')).resolves.not.toThrow();
- });
-
- it('should clear all scoped headers', async () => {
- await browser.setScopedHeaders('https://example.com', { 'X-Test-1': 'value1' });
- await browser.setScopedHeaders('https://example.org', { 'X-Test-2': 'value2' });
- await expect(browser.clearScopedHeaders()).resolves.not.toThrow();
- });
-
- it('should replace headers when called twice for same origin', async () => {
- await browser.clearScopedHeaders();
- await browser.setScopedHeaders('https://example.com', { 'X-First': 'first' });
- // Second call should replace, not add
- await expect(
- browser.setScopedHeaders('https://example.com', { 'X-Second': 'second' })
- ).resolves.not.toThrow();
- await browser.clearScopedHeaders();
- });
-
- it('should handle clearing non-existent origin gracefully', async () => {
- await browser.clearScopedHeaders();
- // Should not throw when clearing headers that were never set
- await expect(browser.clearScopedHeaders('https://never-set.com')).resolves.not.toThrow();
- });
- });
-
- describe('CDP session', () => {
- it('should create CDP session on demand', async () => {
- const cdp = await browser.getCDPSession();
- expect(cdp).toBeDefined();
- });
-
- it('should reuse existing CDP session', async () => {
- const cdp1 = await browser.getCDPSession();
- const cdp2 = await browser.getCDPSession();
- expect(cdp1).toBe(cdp2);
- });
-
- it('should filter out pages with empty URLs during CDP connection', async () => {
- const mockBrowser = {
- contexts: () => [
- {
- pages: () => [
- { url: () => 'http://example.com', on: vi.fn() },
- { url: () => '', on: vi.fn() }, // This page should be filtered out
- { url: () => 'http://anothersite.com', on: vi.fn() },
- ],
- on: vi.fn(),
- setDefaultTimeout: vi.fn(),
- },
- ],
- close: vi.fn(),
- };
- const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
-
- const cdpBrowser = new BrowserManager();
- await cdpBrowser.launch({ cdpPort: 9222 });
-
- // Should have 2 pages, not 3
- expect(cdpBrowser.getPages().length).toBe(2);
-
- // Verify that the empty URL page is not in the list
- const urls = cdpBrowser.getPages().map((p) => p.url());
- expect(urls).not.toContain('');
- expect(urls).toContain('http://example.com');
- spy.mockRestore();
- });
- });
-
- describe('screencast', () => {
- it('should report screencasting state correctly', () => {
- expect(browser.isScreencasting()).toBe(false);
- });
-
- it('should start screencast', async () => {
- const frames: Array<{ data: string }> = [];
- await browser.startScreencast((frame) => {
- frames.push(frame);
- });
- expect(browser.isScreencasting()).toBe(true);
-
- // Wait a bit for at least one frame
- await new Promise((resolve) => setTimeout(resolve, 1000));
-
- await browser.stopScreencast();
- expect(browser.isScreencasting()).toBe(false);
- expect(frames.length).toBeGreaterThan(0);
- });
-
- it('should start screencast with custom options', async () => {
- const frames: Array<{ data: string }> = [];
- await browser.startScreencast(
- (frame) => {
- frames.push(frame);
- },
- {
- format: 'png',
- quality: 100,
- maxWidth: 800,
- maxHeight: 600,
- everyNthFrame: 1,
- }
- );
- expect(browser.isScreencasting()).toBe(true);
-
- // Wait for a frame
- await new Promise((resolve) => setTimeout(resolve, 200));
-
- await browser.stopScreencast();
- expect(frames.length).toBeGreaterThan(0);
- });
-
- it('should throw when starting screencast twice', async () => {
- await browser.startScreencast(() => {});
- await expect(browser.startScreencast(() => {})).rejects.toThrow('Screencast already active');
- await browser.stopScreencast();
- });
-
- it('should handle stop when not screencasting', async () => {
- // Should not throw
- await expect(browser.stopScreencast()).resolves.not.toThrow();
- });
- });
-
- describe('tab switch invalidates CDP session', () => {
- // Clean up any extra tabs before each test
- beforeEach(async () => {
- // Close all tabs except the first one
- const tabs = await browser.listTabs();
- for (let i = tabs.length - 1; i > 0; i--) {
- await browser.closeTab(i);
- }
- // Ensure we're on tab 0
- await browser.switchTo(0);
- // Stop any active screencast
- if (browser.isScreencasting()) {
- await browser.stopScreencast();
- }
- });
-
- it('should not invalidate CDP when switching to same tab', async () => {
- // Get CDP session for current tab
- const cdp1 = await browser.getCDPSession();
-
- // Switch to same tab - should NOT invalidate
- await browser.switchTo(0);
-
- // Should be the same session
- const cdp2 = await browser.getCDPSession();
- expect(cdp2).toBe(cdp1);
- });
-
- it('should invalidate CDP session on tab switch', async () => {
- // Get CDP session for tab 0
- const cdp1 = await browser.getCDPSession();
- expect(cdp1).toBeDefined();
-
- // Create new tab - this switches to the new tab automatically
- await browser.newTab();
-
- // Get CDP session - should be different since we're on a new page
- const cdp2 = await browser.getCDPSession();
- expect(cdp2).toBeDefined();
-
- // Sessions should be different objects (different pages have different CDP sessions)
- expect(cdp2).not.toBe(cdp1);
- });
-
- it('should stop screencast on tab switch', async () => {
- // Start screencast on tab 0
- await browser.startScreencast(() => {});
- expect(browser.isScreencasting()).toBe(true);
-
- // Create new tab and switch
- await browser.newTab();
- await browser.switchTo(1);
-
- // Screencast should be stopped (it's page-specific)
- expect(browser.isScreencasting()).toBe(false);
- });
- });
-
- describe('profiling (CDP tracing)', () => {
- const fs = require('node:fs/promises');
- const path = require('node:path');
- const testOutputDir = '/tmp/agent-browser-test';
-
- beforeAll(async () => {
- // Ensure test output directory exists
- await fs.mkdir(testOutputDir, { recursive: true }).catch(() => {});
- });
-
- afterEach(async () => {
- // Stop profiling if still active
- if (browser.isProfilingActive()) {
- const tempPath = path.join(testOutputDir, 'cleanup.json');
- await browser.stopProfiling(tempPath).catch(() => {});
- }
- });
-
- it('should report profiling state correctly', () => {
- expect(browser.isProfilingActive()).toBe(false);
- });
-
- it('should start profiling', async () => {
- await browser.startProfiling();
- expect(browser.isProfilingActive()).toBe(true);
- });
-
- it('should throw when starting profiling twice', async () => {
- await browser.startProfiling();
- await expect(browser.startProfiling()).rejects.toThrow('Profiling already active');
- });
-
- it('should stop profiling and write file', async () => {
- await browser.startProfiling();
-
- const outputPath = path.join(testOutputDir, 'test-profile.json');
- const result = await browser.stopProfiling(outputPath);
-
- expect(result.path).toBe(outputPath);
- expect(typeof result.eventCount).toBe('number');
- expect(browser.isProfilingActive()).toBe(false);
-
- // Verify file was written
- const fileExists = await fs
- .access(outputPath)
- .then(() => true)
- .catch(() => false);
- expect(fileExists).toBe(true);
-
- // Verify file content is valid JSON with traceEvents
- const content = await fs.readFile(outputPath, 'utf-8');
- const data = JSON.parse(content);
- expect(data).toHaveProperty('traceEvents');
- expect(Array.isArray(data.traceEvents)).toBe(true);
-
- // Cleanup
- await fs.unlink(outputPath).catch(() => {});
- });
-
- it('should throw when stopping without start', async () => {
- expect(browser.isProfilingActive()).toBe(false);
- await expect(browser.stopProfiling('/tmp/should-not-exist.json')).rejects.toThrow(
- 'No profiling session active'
- );
- });
-
- it('should start profiling with custom categories', async () => {
- await browser.startProfiling({ categories: ['devtools.timeline', 'v8.execute'] });
- expect(browser.isProfilingActive()).toBe(true);
-
- // Stop and cleanup
- const outputPath = path.join(testOutputDir, 'custom-categories.json');
- await browser.stopProfiling(outputPath);
- await fs.unlink(outputPath).catch(() => {});
- });
- });
-
- describe('input injection', () => {
- it('should inject mouse move event', async () => {
- await expect(
- browser.injectMouseEvent({
- type: 'mouseMoved',
- x: 100,
- y: 100,
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject mouse click events', async () => {
- await expect(
- browser.injectMouseEvent({
- type: 'mousePressed',
- x: 100,
- y: 100,
- button: 'left',
- clickCount: 1,
- })
- ).resolves.not.toThrow();
-
- await expect(
- browser.injectMouseEvent({
- type: 'mouseReleased',
- x: 100,
- y: 100,
- button: 'left',
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject mouse wheel event', async () => {
- await expect(
- browser.injectMouseEvent({
- type: 'mouseWheel',
- x: 100,
- y: 100,
- deltaX: 0,
- deltaY: 100,
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject keyboard events', async () => {
- await expect(
- browser.injectKeyboardEvent({
- type: 'keyDown',
- key: 'a',
- code: 'KeyA',
- })
- ).resolves.not.toThrow();
-
- await expect(
- browser.injectKeyboardEvent({
- type: 'keyUp',
- key: 'a',
- code: 'KeyA',
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject char event', async () => {
- // CDP char events only accept single characters
- await expect(
- browser.injectKeyboardEvent({
- type: 'char',
- text: 'h',
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject keyboard with modifiers', async () => {
- await expect(
- browser.injectKeyboardEvent({
- type: 'keyDown',
- key: 'c',
- code: 'KeyC',
- modifiers: 2, // Ctrl
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject touch events', async () => {
- await expect(
- browser.injectTouchEvent({
- type: 'touchStart',
- touchPoints: [{ x: 100, y: 100 }],
- })
- ).resolves.not.toThrow();
-
- await expect(
- browser.injectTouchEvent({
- type: 'touchMove',
- touchPoints: [{ x: 150, y: 150 }],
- })
- ).resolves.not.toThrow();
-
- await expect(
- browser.injectTouchEvent({
- type: 'touchEnd',
- touchPoints: [],
- })
- ).resolves.not.toThrow();
- });
-
- it('should inject multi-touch events', async () => {
- await expect(
- browser.injectTouchEvent({
- type: 'touchStart',
- touchPoints: [
- { x: 100, y: 100, id: 0 },
- { x: 200, y: 200, id: 1 },
- ],
- })
- ).resolves.not.toThrow();
-
- await expect(
- browser.injectTouchEvent({
- type: 'touchEnd',
- touchPoints: [],
- })
- ).resolves.not.toThrow();
- });
- });
-});
-
-describe('BrowserManager (persistent context / --profile mode)', () => {
- let profileBrowser: BrowserManager;
- let tmpProfileDir: string;
-
- beforeAll(async () => {
- tmpProfileDir = path.join(os.tmpdir(), `agent-browser-test-profile-${Date.now()}`);
- profileBrowser = new BrowserManager();
- await profileBrowser.launch({ headless: true, profile: tmpProfileDir });
- });
-
- afterAll(async () => {
- await profileBrowser.close();
- rmSync(tmpProfileDir, { recursive: true, force: true });
- });
-
- it('should report as launched in persistent context mode', () => {
- expect(profileBrowser.isLaunched()).toBe(true);
- });
-
- it('should create new tab in persistent context mode without throwing', async () => {
- const result = await profileBrowser.newTab();
- expect(result.index).toBe(1);
- expect(result.total).toBe(2);
- });
-});
-
-describe('getDefaultTimeout', () => {
- const originalEnv = { ...process.env };
-
- afterEach(() => {
- process.env = { ...originalEnv };
- });
-
- it('should return 25000 when env var is not set', () => {
- delete process.env.AGENT_BROWSER_DEFAULT_TIMEOUT;
- expect(getDefaultTimeout()).toBe(25000);
- });
-
- it('should return parsed value when env var is a valid positive integer', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '10000';
- expect(getDefaultTimeout()).toBe(10000);
- });
-
- it('should return 25000 for negative values', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '-1';
- expect(getDefaultTimeout()).toBe(25000);
- });
-
- it('should return 25000 for zero', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '0';
- expect(getDefaultTimeout()).toBe(25000);
- });
-
- it('should return 25000 for values below 1000ms floor', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '500';
- expect(getDefaultTimeout()).toBe(25000);
- });
-
- it('should accept exactly 1000ms as the minimum', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '1000';
- expect(getDefaultTimeout()).toBe(1000);
- });
-
- it('should return 25000 for non-numeric strings', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = 'abc';
- expect(getDefaultTimeout()).toBe(25000);
- });
-
- it('should return 25000 for empty string', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '';
- expect(getDefaultTimeout()).toBe(25000);
- });
-
- it('should allow overriding above 25s for users who need longer timeouts', () => {
- process.env.AGENT_BROWSER_DEFAULT_TIMEOUT = '60000';
- expect(getDefaultTimeout()).toBe(60000);
- });
-});
diff --git a/src/browser.ts b/src/browser.ts
deleted file mode 100644
index 7038dbd..0000000
--- a/src/browser.ts
+++ /dev/null
@@ -1,2834 +0,0 @@
-import {
- chromium,
- firefox,
- webkit,
- devices,
- type Browser,
- type BrowserContext,
- type Page,
- type Frame,
- type Dialog,
- type Request,
- type Route,
- type Locator,
- type CDPSession,
- type Video,
-} from 'playwright-core';
-import path from 'node:path';
-import os from 'node:os';
-import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
-import { writeFile, mkdir } from 'node:fs/promises';
-import type { LaunchCommand, TraceEvent } from './types.js';
-import type { InspectServer } from './inspect-server.js';
-import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
-import { safeHeaderMerge } from './state-utils.js';
-import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
-import {
- getEncryptionKey,
- isEncryptedPayload,
- decryptData,
- ENCRYPTION_KEY_ENV,
-} from './state-utils.js';
-
-/**
- * Returns the default Playwright timeout in milliseconds for standard operations.
- * Can be overridden via the AGENT_BROWSER_DEFAULT_TIMEOUT environment variable.
- * Default is 25s, which is below the CLI's 30s IPC read timeout to ensure
- * Playwright errors are returned before the CLI gives up with EAGAIN.
- * Recording contexts use a shorter fixed timeout (10s) and are not affected.
- */
-export function getDefaultTimeout(): number {
- const envValue = process.env.AGENT_BROWSER_DEFAULT_TIMEOUT;
- if (envValue) {
- const parsed = parseInt(envValue, 10);
- if (!isNaN(parsed) && parsed >= 1000) {
- return parsed;
- }
- }
- return 25000;
-}
-
-/**
- * Handles boolean env vars and parsing (e.g., "true", "1", "false", "0"),
- * with a default value if not set or invalid
- */
-export function parseBooleanEnvVar(name: string, defaultValue: boolean): boolean {
- const truthyVals = ['1', 'true'];
- const falsyVals = ['0', 'false'];
-
- if (!Object.hasOwn(process.env, name)) {
- return defaultValue;
- }
-
- const param = process.env[name]!.toLowerCase();
-
- if (truthyVals.includes(param)) {
- return true;
- }
-
- if (falsyVals.includes(param)) {
- return false;
- }
-
- return defaultValue;
-}
-
-// Screencast frame data from CDP
-export interface ScreencastFrame {
- data: string; // base64 encoded image
- metadata: {
- offsetTop: number;
- pageScaleFactor: number;
- deviceWidth: number;
- deviceHeight: number;
- scrollOffsetX: number;
- scrollOffsetY: number;
- timestamp?: number;
- };
- sessionId: number;
-}
-
-// Screencast options
-export interface ScreencastOptions {
- format?: 'jpeg' | 'png';
- quality?: number; // 0-100, only for jpeg
- maxWidth?: number;
- maxHeight?: number;
- everyNthFrame?: number;
-}
-
-export interface NavigateOptions {
- waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
- headers?: Record;
-}
-
-export type BrowserLaunchOptions = Pick<
- LaunchCommand,
- | 'headless'
- | 'viewport'
- | 'browser'
- | 'headers'
- | 'executablePath'
- | 'cdpPort'
- | 'cdpUrl'
- | 'autoConnect'
- | 'extensions'
- | 'profile'
- | 'storageState'
- | 'proxy'
- | 'args'
- | 'userAgent'
- | 'provider'
- | 'ignoreHTTPSErrors'
- | 'allowFileAccess'
- | 'colorScheme'
- | 'downloadPath'
- | 'allowedDomains'
- | 'autoStateFilePath'
->;
-
-interface TrackedRequest {
- url: string;
- method: string;
- headers: Record;
- timestamp: number;
- resourceType: string;
-}
-
-interface ConsoleMessage {
- type: string;
- text: string;
- timestamp: number;
-}
-
-interface PageError {
- message: string;
- timestamp: number;
-}
-
-/**
- * Manages the Playwright browser lifecycle with multiple tabs/windows
- */
-export class BrowserManager {
- private browser: Browser | null = null;
- private cdpEndpoint: string | null = null; // stores port number or full URL
- private resolvedWsUrl: string | null = null;
- private isPersistentContext: boolean = false;
- private browserbaseSessionId: string | null = null;
- private browserbaseApiKey: string | null = null;
- private browserUseSessionId: string | null = null;
- private browserUseApiKey: string | null = null;
- private kernelSessionId: string | null = null;
- private kernelApiKey: string | null = null;
- private browserlessStopUrl: string | null = null;
- private contexts: BrowserContext[] = [];
- private pages: Page[] = [];
- private activePageIndex: number = 0;
- private activeFrame: Frame | null = null;
- private dialogHandler: ((dialog: Dialog) => Promise) | null = null;
- private trackedRequests: TrackedRequest[] = [];
- private routes: Map Promise> = new Map();
- private consoleMessages: ConsoleMessage[] = [];
- private pageErrors: PageError[] = [];
- private isRecordingHar: boolean = false;
- private refMap: RefMap = {};
- private lastSnapshot: string = '';
- private scopedHeaderRoutes: Map Promise> = new Map();
- private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
- private downloadPath: string | null = null;
- private allowedDomains: string[] = [];
- private inspectServer: InspectServer | null = null;
-
- stopInspectServer(): void {
- if (this.inspectServer) {
- this.inspectServer.stop();
- this.inspectServer = null;
- }
- }
-
- setInspectServer(server: InspectServer): void {
- this.stopInspectServer();
- this.inspectServer = server;
- }
-
- /**
- * Set the persistent color scheme preference.
- * Applied automatically to all new pages and contexts.
- */
- setColorScheme(scheme: 'light' | 'dark' | 'no-preference' | null): void {
- this.colorScheme = scheme;
- }
-
- // CDP session for screencast and input injection
- private cdpSession: CDPSession | null = null;
- private screencastActive: boolean = false;
- private screencastSessionId: number = 0;
- private frameCallback: ((frame: ScreencastFrame) => void) | null = null;
- private screencastFrameHandler: ((params: any) => void) | null = null;
-
- // Video recording (Playwright native)
- private recordingContext: BrowserContext | null = null;
- private recordingPage: Page | null = null;
- private recordingOutputPath: string = '';
- private recordingTempDir: string = '';
- private launchWarnings: string[] = [];
-
- /**
- * Get and clear launch warnings (e.g., decryption failures)
- */
- getAndClearWarnings(): string[] {
- const warnings = this.launchWarnings;
- this.launchWarnings = [];
- return warnings;
- }
-
- // CDP profiling state
- private static readonly MAX_PROFILE_EVENTS = 5_000_000;
- private profilingActive: boolean = false;
- private profileChunks: TraceEvent[] = [];
- private profileEventsDropped: boolean = false;
- private profileCompleteResolver: (() => void) | null = null;
- private profileDataHandler: ((params: { value?: TraceEvent[] }) => void) | null = null;
- private profileCompleteHandler: (() => void) | null = null;
-
- /**
- * Check if browser is launched
- */
- isLaunched(): boolean {
- return this.browser !== null || this.isPersistentContext;
- }
-
- getCdpUrl(): string | null {
- if (this.resolvedWsUrl) return this.resolvedWsUrl;
- if (this.cdpEndpoint?.startsWith('ws://') || this.cdpEndpoint?.startsWith('wss://')) {
- return this.cdpEndpoint;
- }
- try {
- return (this.browser as any)?.wsEndpoint?.() ?? null;
- } catch {
- return null;
- }
- }
-
- /**
- * Get enhanced snapshot with refs and cache the ref map
- */
- async getSnapshot(options?: {
- interactive?: boolean;
- cursor?: boolean;
- maxDepth?: number;
- compact?: boolean;
- selector?: string;
- }): Promise {
- const page = this.getPage();
- const snapshot = await getEnhancedSnapshot(page, options);
- this.refMap = snapshot.refs;
- this.lastSnapshot = snapshot.tree;
- return snapshot;
- }
-
- /**
- * Get the last snapshot tree text (empty string if no snapshot has been taken)
- */
- getLastSnapshot(): string {
- return this.lastSnapshot;
- }
-
- /**
- * Update the stored snapshot (used by diff to keep the baseline current)
- */
- setLastSnapshot(snapshot: string): void {
- this.lastSnapshot = snapshot;
- }
-
- /**
- * Get the cached ref map from last snapshot
- */
- getRefMap(): RefMap {
- return this.refMap;
- }
-
- /**
- * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
- * Returns null if ref doesn't exist or is invalid
- */
- getLocatorFromRef(refArg: string): Locator | null {
- const ref = parseRef(refArg);
- if (!ref) return null;
-
- const refData = this.refMap[ref];
- if (!refData) return null;
-
- const page = this.getPage();
-
- // Check if this is a cursor-interactive element (uses CSS selector, not ARIA role)
- // These have pseudo-roles 'clickable' or 'focusable' and a CSS selector
- if (refData.role === 'clickable' || refData.role === 'focusable') {
- // The selector is a CSS selector, use it directly
- return page.locator(refData.selector);
- }
-
- // Build locator with exact: true to avoid substring matches
- let locator: Locator = page.getByRole(refData.role as any, {
- name: refData.name,
- exact: true,
- });
-
- // If an nth index is stored (for disambiguation), use it
- if (refData.nth !== undefined) {
- locator = locator.nth(refData.nth);
- }
-
- return locator;
- }
-
- /**
- * Check if a selector looks like a ref
- */
- isRef(selector: string): boolean {
- return parseRef(selector) !== null;
- }
-
- /**
- * Install the domain filter on a context if an allowlist is configured.
- * Should be called before any pages navigate on the context.
- */
- private async ensureDomainFilter(context: BrowserContext): Promise {
- if (this.allowedDomains.length > 0) {
- await installDomainFilter(context, this.allowedDomains);
- }
- }
-
- /**
- * After installing the domain filter, verify existing pages are on allowed
- * domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
- * already navigated to disallowed domains. Navigate them to about:blank.
- */
- private async sanitizeExistingPages(pages: Page[]): Promise {
- if (this.allowedDomains.length === 0) return;
- for (const page of pages) {
- const url = page.url();
- if (!url || url === 'about:blank') continue;
- try {
- const hostname = new URL(url).hostname.toLowerCase();
- if (!isDomainAllowed(hostname, this.allowedDomains)) {
- await page.goto('about:blank');
- }
- } catch {
- await page.goto('about:blank').catch(() => {});
- }
- }
- }
-
- /**
- * Check if a URL is allowed by the domain allowlist.
- * Throws if the URL's domain is blocked. No-op if no allowlist is set.
- * Blocks non-http(s) schemes and unparseable URLs by default.
- */
- checkDomainAllowed(url: string): void {
- if (this.allowedDomains.length === 0) return;
-
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
- throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
- }
-
- let hostname: string;
- try {
- hostname = new URL(url).hostname.toLowerCase();
- } catch {
- throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
- }
-
- if (!isDomainAllowed(hostname, this.allowedDomains)) {
- throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
- }
- }
-
- /**
- * Get locator - supports both refs and regular selectors
- */
- getLocator(selectorOrRef: string): Locator {
- // Check if it's a ref first
- const locator = this.getLocatorFromRef(selectorOrRef);
- if (locator) return locator;
-
- // Otherwise treat as regular selector
- const page = this.getPage();
- return page.locator(selectorOrRef);
- }
-
- /**
- * Check if the browser has any usable pages
- */
- hasPages(): boolean {
- return this.pages.length > 0;
- }
-
- /**
- * Ensure at least one page exists. If the browser is launched but all pages
- * were closed (stale session), creates a new page on the existing context.
- * No-op if pages already exist.
- */
- async ensurePage(): Promise {
- if (this.pages.length > 0) return;
- if (!this.browser && !this.isPersistentContext) return;
-
- // Use the last existing context, or create a new one
- let context: BrowserContext;
- if (this.contexts.length > 0) {
- context = this.contexts[this.contexts.length - 1];
- } else if (this.browser) {
- context = await this.browser.newContext({
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
- });
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
- } else {
- return;
- }
-
- const page = await context.newPage();
- if (!this.pages.includes(page)) {
- this.pages.push(page);
- this.setupPageTracking(page);
- }
- this.activePageIndex = this.pages.length - 1;
- }
-
- /**
- * Get the current active page, throws if not launched
- */
- getPage(): Page {
- if (this.pages.length === 0) {
- throw new Error('Browser not launched. Call launch first.');
- }
- return this.pages[this.activePageIndex];
- }
-
- /**
- * Get the current frame (or page's main frame if no frame is selected)
- */
- getFrame(): Frame {
- if (this.activeFrame) {
- return this.activeFrame;
- }
- return this.getPage().mainFrame();
- }
-
- /**
- * Switch to a frame by selector, name, or URL
- */
- async switchToFrame(options: { selector?: string; name?: string; url?: string }): Promise {
- const page = this.getPage();
-
- if (options.selector) {
- const frameElement = await page.$(options.selector);
- if (!frameElement) {
- throw new Error(`Frame not found: ${options.selector}`);
- }
- const frame = await frameElement.contentFrame();
- if (!frame) {
- throw new Error(`Element is not a frame: ${options.selector}`);
- }
- this.activeFrame = frame;
- } else if (options.name) {
- const frame = page.frame({ name: options.name });
- if (!frame) {
- throw new Error(`Frame not found with name: ${options.name}`);
- }
- this.activeFrame = frame;
- } else if (options.url) {
- const frame = page.frame({ url: options.url });
- if (!frame) {
- throw new Error(`Frame not found with URL: ${options.url}`);
- }
- this.activeFrame = frame;
- }
- }
-
- /**
- * Navigate the active page to a URL and return the resolved URL + title.
- * If the browser is launched but all pages have been closed, a new page is
- * created automatically before navigating (stale-session recovery).
- */
- async navigate(
- url: string,
- options: NavigateOptions = {}
- ): Promise<{ url: string; title: string }> {
- this.checkDomainAllowed(url);
- await this.ensurePage();
-
- if (options.headers && Object.keys(options.headers).length > 0) {
- await this.setScopedHeaders(url, options.headers);
- }
-
- const page = this.getPage();
- await page.goto(url, {
- waitUntil: options.waitUntil ?? 'load',
- });
-
- return {
- url: page.url(),
- title: await page.title(),
- };
- }
-
- /**
- * Get the active page URL.
- */
- async getUrl(): Promise {
- await this.ensurePage();
- return this.getPage().url();
- }
-
- /**
- * Get the active page title.
- */
- async getTitle(): Promise {
- await this.ensurePage();
- return this.getPage().title();
- }
-
- /**
- * Switch back to main frame
- */
- switchToMainFrame(): void {
- this.activeFrame = null;
- }
-
- /**
- * Set up dialog handler
- */
- setDialogHandler(response: 'accept' | 'dismiss', promptText?: string): void {
- const page = this.getPage();
-
- // Remove existing handler if any
- if (this.dialogHandler) {
- page.removeListener('dialog', this.dialogHandler);
- }
-
- this.dialogHandler = async (dialog: Dialog) => {
- if (response === 'accept') {
- await dialog.accept(promptText);
- } else {
- await dialog.dismiss();
- }
- };
-
- page.on('dialog', this.dialogHandler);
- }
-
- /**
- * Clear dialog handler
- */
- clearDialogHandler(): void {
- if (this.dialogHandler) {
- const page = this.getPage();
- page.removeListener('dialog', this.dialogHandler);
- this.dialogHandler = null;
- }
- }
-
- /**
- * Start tracking requests
- */
- startRequestTracking(): void {
- const page = this.getPage();
- page.on('request', (request: Request) => {
- this.trackedRequests.push({
- url: request.url(),
- method: request.method(),
- headers: request.headers(),
- timestamp: Date.now(),
- resourceType: request.resourceType(),
- });
- });
- }
-
- /**
- * Get tracked requests
- */
- getRequests(filter?: string): TrackedRequest[] {
- if (filter) {
- return this.trackedRequests.filter((r) => r.url.includes(filter));
- }
- return this.trackedRequests;
- }
-
- /**
- * Clear tracked requests
- */
- clearRequests(): void {
- this.trackedRequests = [];
- }
-
- /**
- * Add a route to intercept requests
- */
- async addRoute(
- url: string,
- options: {
- response?: {
- status?: number;
- body?: string;
- contentType?: string;
- headers?: Record;
- };
- abort?: boolean;
- }
- ): Promise {
- const page = this.getPage();
-
- const handler = async (route: Route) => {
- if (options.abort) {
- await route.abort();
- } else if (options.response) {
- await route.fulfill({
- status: options.response.status ?? 200,
- body: options.response.body ?? '',
- contentType: options.response.contentType ?? 'text/plain',
- headers: options.response.headers,
- });
- } else {
- await route.continue();
- }
- };
-
- this.routes.set(url, handler);
- await page.route(url, handler);
- }
-
- /**
- * Remove a route
- */
- async removeRoute(url?: string): Promise {
- const page = this.getPage();
-
- if (url) {
- const handler = this.routes.get(url);
- if (handler) {
- await page.unroute(url, handler);
- this.routes.delete(url);
- }
- } else {
- // Remove all routes
- for (const [routeUrl, handler] of this.routes) {
- await page.unroute(routeUrl, handler);
- }
- this.routes.clear();
- }
- }
-
- /**
- * Set geolocation
- */
- async setGeolocation(latitude: number, longitude: number, accuracy?: number): Promise {
- const context = this.contexts[0];
- if (context) {
- await context.setGeolocation({ latitude, longitude, accuracy });
- }
- }
-
- /**
- * Set permissions
- */
- async setPermissions(permissions: string[], grant: boolean): Promise {
- const context = this.contexts[0];
- if (context) {
- if (grant) {
- await context.grantPermissions(permissions);
- } else {
- await context.clearPermissions();
- }
- }
- }
-
- /**
- * Set viewport
- */
- async setViewport(width: number, height: number): Promise {
- const page = this.getPage();
- await page.setViewportSize({ width, height });
- }
-
- /**
- * Set device scale factor (devicePixelRatio) via CDP
- * This sets window.devicePixelRatio which affects how the page renders and responds to media queries
- *
- * Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions
- * (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation
- * when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels,
- * deviceScaleFactor must be set at context creation time.
- *
- * Must be called after setViewport to work correctly
- */
- async setDeviceScaleFactor(
- deviceScaleFactor: number,
- width: number,
- height: number,
- mobile: boolean = false
- ): Promise {
- const cdp = await this.getCDPSession();
- await cdp.send('Emulation.setDeviceMetricsOverride', {
- width,
- height,
- deviceScaleFactor,
- mobile,
- });
- }
-
- /**
- * Clear device metrics override to restore default devicePixelRatio
- */
- async clearDeviceMetricsOverride(): Promise {
- const cdp = await this.getCDPSession();
- await cdp.send('Emulation.clearDeviceMetricsOverride');
- }
-
- /**
- * Get device descriptor
- */
- getDevice(deviceName: string): (typeof devices)[keyof typeof devices] | undefined {
- return devices[deviceName as keyof typeof devices];
- }
-
- /**
- * List available devices
- */
- listDevices(): string[] {
- return Object.keys(devices);
- }
-
- /**
- * Start console message tracking
- */
- startConsoleTracking(): void {
- const page = this.getPage();
- page.on('console', (msg) => {
- this.consoleMessages.push({
- type: msg.type(),
- text: msg.text(),
- timestamp: Date.now(),
- });
- });
- }
-
- /**
- * Get console messages
- */
- getConsoleMessages(): ConsoleMessage[] {
- return this.consoleMessages;
- }
-
- /**
- * Clear console messages
- */
- clearConsoleMessages(): void {
- this.consoleMessages = [];
- }
-
- /**
- * Start error tracking
- */
- startErrorTracking(): void {
- const page = this.getPage();
- page.on('pageerror', (error) => {
- this.pageErrors.push({
- message: error.message,
- timestamp: Date.now(),
- });
- });
- }
-
- /**
- * Get page errors
- */
- getPageErrors(): PageError[] {
- return this.pageErrors;
- }
-
- /**
- * Clear page errors
- */
- clearPageErrors(): void {
- this.pageErrors = [];
- }
-
- /**
- * Start HAR recording
- */
- async startHarRecording(): Promise {
- // HAR is started at context level, flag for tracking
- this.isRecordingHar = true;
- }
-
- /**
- * Check if HAR recording
- */
- isHarRecording(): boolean {
- return this.isRecordingHar;
- }
-
- /**
- * Set offline mode
- */
- async setOffline(offline: boolean): Promise {
- const context = this.contexts[0];
- if (context) {
- await context.setOffline(offline);
- }
- }
-
- /**
- * Set extra HTTP headers (global - all requests)
- */
- async setExtraHeaders(headers: Record): Promise {
- const context = this.contexts[0];
- if (context) {
- await context.setExtraHTTPHeaders(headers);
- }
- }
-
- /**
- * Set scoped HTTP headers (only for requests matching the origin)
- * Uses route interception to add headers only to matching requests
- */
- async setScopedHeaders(origin: string, headers: Record): Promise {
- const page = this.getPage();
-
- // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
- // Handle both full URLs and just hostnames
- let urlPattern: string;
- try {
- const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
- // Match any protocol, the host, and any path
- urlPattern = `**://${url.host}/**`;
- } catch {
- // If parsing fails, treat as hostname pattern
- urlPattern = `**://${origin}/**`;
- }
-
- // Remove existing route for this origin if any
- const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
- if (existingHandler) {
- await page.unroute(urlPattern, existingHandler);
- }
-
- // Create handler that adds headers to matching requests
- const handler = async (route: Route) => {
- const requestHeaders = route.request().headers();
- await route.continue({
- headers: safeHeaderMerge(requestHeaders, headers),
- });
- };
-
- // Store and register the route
- this.scopedHeaderRoutes.set(urlPattern, handler);
- await page.route(urlPattern, handler);
- }
-
- /**
- * Clear scoped headers for an origin (or all if no origin specified)
- */
- async clearScopedHeaders(origin?: string): Promise {
- const page = this.getPage();
-
- if (origin) {
- let urlPattern: string;
- try {
- const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
- urlPattern = `**://${url.host}/**`;
- } catch {
- urlPattern = `**://${origin}/**`;
- }
-
- const handler = this.scopedHeaderRoutes.get(urlPattern);
- if (handler) {
- await page.unroute(urlPattern, handler);
- this.scopedHeaderRoutes.delete(urlPattern);
- }
- } else {
- // Clear all scoped header routes
- for (const [pattern, handler] of this.scopedHeaderRoutes) {
- await page.unroute(pattern, handler);
- }
- this.scopedHeaderRoutes.clear();
- }
- }
-
- /**
- * Start tracing
- */
- async startTracing(options: { screenshots?: boolean; snapshots?: boolean }): Promise {
- const context = this.contexts[0];
- if (context) {
- await context.tracing.start({
- screenshots: options.screenshots ?? true,
- snapshots: options.snapshots ?? true,
- });
- }
- }
-
- /**
- * Stop tracing and save
- */
- async stopTracing(path?: string): Promise {
- const context = this.contexts[0];
- if (context) {
- await context.tracing.stop(path ? { path } : undefined);
- }
- }
-
- /**
- * Get the current browser context (first context)
- */
- getContext(): BrowserContext | null {
- return this.contexts[0] ?? null;
- }
-
- /**
- * Save storage state (cookies, localStorage, etc.)
- */
- async saveStorageState(path: string): Promise {
- const context = this.contexts[0];
- if (context) {
- await context.storageState({ path });
- }
- }
-
- /**
- * Get all pages
- */
- getPages(): Page[] {
- return this.pages;
- }
-
- /**
- * Get current page index
- */
- getActiveIndex(): number {
- return this.activePageIndex;
- }
-
- /**
- * Get the current browser instance
- */
- getBrowser(): Browser | null {
- return this.browser;
- }
-
- /**
- * Check if an existing CDP connection is still alive
- * by verifying we can access browser contexts and that at least one has pages
- */
- private isCdpConnectionAlive(): boolean {
- if (!this.browser) return false;
- try {
- const contexts = this.browser.contexts();
- if (contexts.length === 0) return false;
- return contexts.some((context) => context.pages().length > 0);
- } catch {
- return false;
- }
- }
-
- /**
- * Check if CDP connection needs to be re-established
- */
- private needsCdpReconnect(cdpEndpoint: string): boolean {
- if (!this.browser?.isConnected()) return true;
- if (this.cdpEndpoint !== cdpEndpoint) return true;
- if (!this.isCdpConnectionAlive()) return true;
- return false;
- }
-
- /**
- * Close a Browserbase session via API
- */
- private async closeBrowserbaseSession(sessionId: string, apiKey: string): Promise {
- const response = await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-BB-API-Key': apiKey,
- },
- body: JSON.stringify({ status: 'REQUEST_RELEASE' }),
- });
-
- if (!response.ok) {
- throw new Error(`Failed to close Browserbase session: ${response.statusText}`);
- }
- }
-
- /**
- * Close a Browser Use session via API
- */
- private async closeBrowserUseSession(sessionId: string, apiKey: string): Promise {
- const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Browser-Use-API-Key': apiKey,
- },
- body: JSON.stringify({ action: 'stop' }),
- });
-
- if (!response.ok) {
- throw new Error(`Failed to close Browser Use session: ${response.statusText}`);
- }
- }
-
- /**
- * Close a Kernel session via API
- */
- private async closeKernelSession(sessionId: string, apiKey: string | undefined): Promise {
- const headers: Record = {};
- if (apiKey) {
- headers['Authorization'] = `Bearer ${apiKey}`;
- }
- const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, {
- method: 'DELETE',
- headers,
- });
-
- if (!response.ok) {
- throw new Error(`Failed to close Kernel session: ${response.statusText}`);
- }
- }
-
- /**
- * Close a Browserless session via its stop URL
- */
- private async closeBrowserlessSession(stopUrl: string): Promise {
- const response = await fetch(stopUrl, {
- method: 'DELETE',
- });
-
- if (!response.ok) {
- throw new Error(`Failed to close Browserless session: ${response.statusText}`);
- }
- }
-
- /**
- * Connect to Browserbase remote browser via CDP.
- * Requires BROWSERBASE_API_KEY environment variable.
- */
- private async connectToBrowserbase(): Promise {
- const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
-
- if (!browserbaseApiKey) {
- throw new Error('BROWSERBASE_API_KEY is required when using browserbase as a provider');
- }
-
- const response = await fetch('https://api.browserbase.com/v1/sessions', {
- method: 'POST',
- headers: {
- 'X-BB-API-Key': browserbaseApiKey,
- },
- });
-
- if (!response.ok) {
- throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
- }
-
- const session = (await response.json()) as { id: string; connectUrl: string };
-
- const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => {
- throw new Error('Failed to connect to Browserbase session via CDP');
- });
-
- try {
- const contexts = browser.contexts();
- if (contexts.length === 0) {
- throw new Error('No browser context found in Browserbase session');
- }
-
- const context = contexts[0];
- const pages = context.pages();
- const page = pages[0] ?? (await context.newPage());
-
- this.browserbaseSessionId = session.id;
- this.browserbaseApiKey = browserbaseApiKey;
- this.browser = browser;
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
- await this.sanitizeExistingPages([page]);
- this.pages.push(page);
- this.activePageIndex = 0;
- this.setupPageTracking(page);
- } catch (error) {
- await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => {
- console.error('Failed to close Browserbase session during cleanup:', sessionError);
- });
- throw error;
- }
- }
-
- /**
- * Find or create a Kernel profile by name.
- * Returns the profile object if successful.
- */
- private async findOrCreateKernelProfile(
- profileName: string,
- apiKey: string | undefined
- ): Promise<{ name: string }> {
- const headers: Record = {};
- if (apiKey) {
- headers['Authorization'] = `Bearer ${apiKey}`;
- }
-
- // First, try to get the existing profile
- const getResponse = await fetch(
- `https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`,
- {
- method: 'GET',
- headers,
- }
- );
-
- if (getResponse.ok) {
- // Profile exists, return it
- return { name: profileName };
- }
-
- if (getResponse.status !== 404) {
- throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`);
- }
-
- // Profile doesn't exist, create it
- const createResponse = await fetch('https://api.onkernel.com/profiles', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...headers,
- },
- body: JSON.stringify({ name: profileName }),
- });
-
- if (!createResponse.ok) {
- throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`);
- }
-
- return { name: profileName };
- }
-
- /**
- * Connect to Kernel remote browser via CDP.
- * Uses KERNEL_API_KEY environment variable for authentication when set.
- * When running inside environments with external credential injection
- * (e.g. Vercel Sandbox credentials brokering), the API key can be omitted
- * and auth headers will be injected at the network layer.
- */
- private async connectToKernel(): Promise {
- const kernelApiKey = process.env.KERNEL_API_KEY;
-
- // Find or create profile if KERNEL_PROFILE_NAME is set
- const profileName = process.env.KERNEL_PROFILE_NAME;
- let profileConfig: { profile: { name: string; save_changes: boolean } } | undefined;
-
- if (profileName) {
- await this.findOrCreateKernelProfile(profileName, kernelApiKey);
- profileConfig = {
- profile: {
- name: profileName,
- save_changes: true, // Save cookies/state back to the profile when session ends
- },
- };
- }
-
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
- if (kernelApiKey) {
- headers['Authorization'] = `Bearer ${kernelApiKey}`;
- }
-
- const response = await fetch('https://api.onkernel.com/browsers', {
- method: 'POST',
- headers,
- body: JSON.stringify({
- // Kernel browsers are headful by default with stealth mode available
- // The user can configure these via environment variables if needed
- headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true',
- stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode
- timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10),
- // Load and save to a profile if specified
- ...profileConfig,
- }),
- });
-
- if (!response.ok) {
- throw new Error(`Failed to create Kernel session: ${response.statusText}`);
- }
-
- let session: { session_id: string; cdp_ws_url: string };
- try {
- session = (await response.json()) as { session_id: string; cdp_ws_url: string };
- } catch (error) {
- throw new Error(
- `Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}`
- );
- }
-
- if (!session.session_id || !session.cdp_ws_url) {
- throw new Error(
- `Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}`
- );
- }
-
- const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => {
- throw new Error('Failed to connect to Kernel session via CDP');
- });
-
- try {
- const contexts = browser.contexts();
- let context: BrowserContext;
- let page: Page;
-
- // Kernel browsers launch with a default context and page
- if (contexts.length === 0) {
- context = await browser.newContext();
- page = await context.newPage();
- } else {
- context = contexts[0];
- const pages = context.pages();
- page = pages[0] ?? (await context.newPage());
- }
-
- this.kernelSessionId = session.session_id;
- this.kernelApiKey = kernelApiKey ?? null;
- this.browser = browser;
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
- await this.sanitizeExistingPages([page]);
- this.pages.push(page);
- this.activePageIndex = 0;
- this.setupPageTracking(page);
- } catch (error) {
- await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
- console.error('Failed to close Kernel session during cleanup:', sessionError);
- });
- throw error;
- }
- }
-
- /**
- * Connect to Browser Use remote browser via CDP.
- * Requires BROWSER_USE_API_KEY environment variable.
- */
- private async connectToBrowserUse(): Promise {
- const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
- if (!browserUseApiKey) {
- throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
- }
-
- const response = await fetch('https://api.browser-use.com/api/v2/browsers', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Browser-Use-API-Key': browserUseApiKey,
- },
- body: JSON.stringify({}),
- });
-
- if (!response.ok) {
- throw new Error(`Failed to create Browser Use session: ${response.statusText}`);
- }
-
- let session: { id: string; cdpUrl: string };
- try {
- session = (await response.json()) as { id: string; cdpUrl: string };
- } catch (error) {
- throw new Error(
- `Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}`
- );
- }
-
- if (!session.id || !session.cdpUrl) {
- throw new Error(
- `Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}`
- );
- }
-
- const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => {
- throw new Error('Failed to connect to Browser Use session via CDP');
- });
-
- try {
- const contexts = browser.contexts();
- let context: BrowserContext;
- let page: Page;
-
- if (contexts.length === 0) {
- context = await browser.newContext();
- page = await context.newPage();
- } else {
- context = contexts[0];
- const pages = context.pages();
- page = pages[0] ?? (await context.newPage());
- }
-
- this.browserUseSessionId = session.id;
- this.browserUseApiKey = browserUseApiKey;
- this.browser = browser;
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
- await this.sanitizeExistingPages([page]);
- this.pages.push(page);
- this.activePageIndex = 0;
- this.setupPageTracking(page);
- } catch (error) {
- await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
- console.error('Failed to close Browser Use session during cleanup:', sessionError);
- });
- throw error;
- }
- }
-
- /**
- * Connect to Browserless remote browser via CDP.
- * Requires BROWSERLESS_API_KEY environment variable.
- */
- private async connectToBrowserless(): Promise {
- const browserlessToken = process.env.BROWSERLESS_API_KEY;
- if (!browserlessToken) {
- throw new Error('BROWSERLESS_API_KEY is required when using browserless as a provider');
- }
-
- const supportedBrowsers = ['chromium', 'chrome'];
- const apiUrl = process.env.BROWSERLESS_API_URL || 'https://production-sfo.browserless.io';
- const browserType = process.env.BROWSERLESS_BROWSER_TYPE || 'chromium';
- const ttl = parseInt(process.env.BROWSERLESS_TTL || '300000', 10);
- const stealth = parseBooleanEnvVar('BROWSERLESS_STEALTH', true);
-
- if (!supportedBrowsers.includes(browserType)) {
- throw new Error(
- `BROWSERLESS_BROWSER_TYPE "${browserType}" is not supported. Only ${supportedBrowsers.join(', ')} are allowed.`
- );
- }
-
- const response = await fetch(
- `${apiUrl}/session?token=${encodeURIComponent(browserlessToken)}`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- ttl,
- stealth,
- browser: browserType,
- }),
- }
- );
-
- if (!response.ok) {
- throw new Error(`Failed to create Browserless session: ${response.statusText}`);
- }
-
- let session: { connect: string; stop: string };
- try {
- session = (await response.json()) as { connect: string; stop: string };
- } catch (error) {
- throw new Error(
- `Failed to parse Browserless session response: ${error instanceof Error ? error.message : String(error)}`
- );
- }
-
- if (!session.connect || !session.stop) {
- throw new Error(
- `Invalid Browserless session response: missing ${!session.connect ? 'connect' : 'stop'}`
- );
- }
-
- const browser = await chromium.connectOverCDP(session.connect).catch(() => {
- throw new Error('Failed to connect to Browserless session via CDP');
- });
-
- try {
- const contexts = browser.contexts();
- let context: BrowserContext;
- let page: Page;
-
- if (contexts.length === 0) {
- context = await browser.newContext();
- page = await context.newPage();
- } else {
- context = contexts[0];
- const pages = context.pages();
- page = pages[0] ?? (await context.newPage());
- }
-
- this.browser = browser;
- this.browserlessStopUrl = session.stop;
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
- await this.sanitizeExistingPages([page]);
- this.pages.push(page);
- this.activePageIndex = 0;
- this.setupPageTracking(page);
- } catch (error) {
- await this.closeBrowserlessSession(session.stop).catch((sessionError) => {
- console.error('Failed to close Browserless session during cleanup:', sessionError);
- });
- this.browserlessStopUrl = null;
- throw error;
- }
- }
-
- /**
- * Launch the browser with the specified options
- * If already launched, this is a no-op (browser stays open)
- */
- async launch(options: BrowserLaunchOptions): Promise {
- // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
- const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
- const hasExtensions = !!options.extensions?.length;
- const hasProfile = !!options.profile;
- const hasStorageState = !!options.storageState;
-
- if (hasExtensions && cdpEndpoint) {
- throw new Error('Extensions cannot be used with CDP connection');
- }
-
- if (hasProfile && cdpEndpoint) {
- throw new Error('Profile cannot be used with CDP connection');
- }
-
- if (hasStorageState && hasProfile) {
- throw new Error(
- 'Storage state cannot be used with profile (profile is already persistent storage)'
- );
- }
-
- if (hasStorageState && hasExtensions) {
- throw new Error(
- 'Storage state cannot be used with extensions (extensions require persistent context)'
- );
- }
-
- if (this.isLaunched()) {
- const needsRelaunch =
- (!cdpEndpoint && !options.autoConnect && this.cdpEndpoint !== null) ||
- (!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)) ||
- (!!options.autoConnect && !this.isCdpConnectionAlive());
- if (needsRelaunch) {
- await this.close();
- } else if (options.autoConnect && this.isCdpConnectionAlive()) {
- // Already connected via auto-connect, no need to reconnect
- return;
- } else {
- return;
- }
- }
-
- if (options.colorScheme) {
- this.colorScheme = options.colorScheme;
- }
-
- if (options.downloadPath) {
- this.downloadPath = options.downloadPath;
- }
-
- if (options.allowedDomains && options.allowedDomains.length > 0) {
- this.allowedDomains = options.allowedDomains.map((d: string) => d.toLowerCase());
- } else {
- const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS;
- if (envDomains) {
- this.allowedDomains = parseDomainList(envDomains);
- }
- }
-
- if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
- const warning =
- "--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
- this.launchWarnings.push(warning);
- console.error(`[WARN] ${warning}`);
- }
-
- if (cdpEndpoint) {
- await this.connectViaCDP(cdpEndpoint);
- return;
- }
-
- if (options.autoConnect) {
- await this.autoConnectViaCDP();
- return;
- }
-
- // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
- // -p flag takes precedence over env var
- const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
- if (this.downloadPath && provider) {
- const warning =
- "--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
- this.launchWarnings.push(warning);
- console.error(`[WARN] ${warning}`);
- }
- if (provider === 'browserbase') {
- await this.connectToBrowserbase();
- return;
- }
- if (provider === 'browseruse') {
- await this.connectToBrowserUse();
- return;
- }
-
- // Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel
- if (provider === 'kernel') {
- await this.connectToKernel();
- return;
- }
- if (provider === 'browserless') {
- await this.connectToBrowserless();
- return;
- }
-
- if (this.downloadPath) {
- const resolved = path.resolve(this.downloadPath);
- const stat = statSync(resolved, { throwIfNoEntry: false });
- if (stat && !stat.isDirectory()) {
- throw new Error(`Download path is not a directory: ${resolved}`);
- }
- if (!stat) {
- try {
- mkdirSync(resolved, { recursive: true });
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : String(e);
- throw new Error(`Cannot create download directory '${resolved}': ${msg}`);
- }
- }
- this.downloadPath = resolved;
- }
-
- const browserType = options.browser ?? 'chromium';
- if (hasExtensions && browserType !== 'chromium') {
- throw new Error('Extensions are only supported in Chromium');
- }
-
- // allowFileAccess is only supported in Chromium
- if (options.allowFileAccess && browserType !== 'chromium') {
- throw new Error('allowFileAccess is only supported in Chromium');
- }
-
- const launcher =
- browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
-
- // Build base args array with file access flags if enabled
- // --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch
- // --allow-file-access: allows the browser to access local files in general
- const fileAccessArgs = options.allowFileAccess
- ? ['--allow-file-access-from-files', '--allow-file-access']
- : [];
- const baseArgs = options.args
- ? [...fileAccessArgs, ...options.args]
- : fileAccessArgs.length > 0
- ? fileAccessArgs
- : undefined;
-
- // Auto-detect args that control window size and disable viewport emulation
- // so Playwright doesn't override the browser's own sizing behavior
- const hasWindowSizeArgs = baseArgs?.some(
- (arg) => arg === '--start-maximized' || arg.startsWith('--window-size=')
- );
- const viewport =
- options.viewport !== undefined
- ? options.viewport
- : hasWindowSizeArgs
- ? null
- : { width: 1280, height: 720 };
-
- let context: BrowserContext;
- if (hasExtensions) {
- // Extensions require persistent context in a temp directory
- const extPaths = options.extensions!.join(',');
- const session = process.env.AGENT_BROWSER_SESSION || 'default';
- // Combine extension args with custom args and file access args
- const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
- const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs;
- context = await launcher.launchPersistentContext(
- path.join(os.tmpdir(), `agent-browser-ext-${session}`),
- {
- headless: options.headless ?? true,
- executablePath: options.executablePath,
- args: allArgs,
- viewport,
- extraHTTPHeaders: options.headers,
- userAgent: options.userAgent,
- ...(options.proxy && { proxy: options.proxy }),
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
- ...(this.downloadPath && { downloadsPath: this.downloadPath }),
- }
- );
- this.isPersistentContext = true;
- } else if (hasProfile) {
- // Profile uses persistent context for durable cookies/storage
- // Expand ~ to home directory since it won't be shell-expanded
- const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/');
- context = await launcher.launchPersistentContext(profilePath, {
- headless: options.headless ?? true,
- executablePath: options.executablePath,
- args: baseArgs,
- viewport,
- extraHTTPHeaders: options.headers,
- userAgent: options.userAgent,
- ...(options.proxy && { proxy: options.proxy }),
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
- ...(this.downloadPath && { downloadsPath: this.downloadPath }),
- });
- this.isPersistentContext = true;
- } else {
- // Regular ephemeral browser
- this.browser = await launcher.launch({
- headless: options.headless ?? true,
- executablePath: options.executablePath,
- args: baseArgs,
- ...(this.downloadPath && { downloadsPath: this.downloadPath }),
- });
- this.cdpEndpoint = null;
- this.resolvedWsUrl = null;
-
- // Check for auto-load state file (supports encrypted files)
- let storageState:
- | string
- | {
- cookies: Array<{
- name: string;
- value: string;
- domain: string;
- path: string;
- expires: number;
- httpOnly: boolean;
- secure: boolean;
- sameSite: 'Strict' | 'Lax' | 'None';
- }>;
- origins: Array<{
- origin: string;
- localStorage: Array<{ name: string; value: string }>;
- }>;
- }
- | undefined = options.storageState ? options.storageState : undefined;
-
- if (!storageState && options.autoStateFilePath) {
- try {
- const fs = await import('fs');
- if (fs.existsSync(options.autoStateFilePath)) {
- const content = fs.readFileSync(options.autoStateFilePath, 'utf8');
- const parsed = JSON.parse(content);
-
- if (isEncryptedPayload(parsed)) {
- const key = getEncryptionKey();
- if (key) {
- try {
- const decrypted = decryptData(parsed, key);
- storageState = JSON.parse(decrypted);
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(
- `[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}`
- );
- }
- } catch (decryptErr) {
- const warning =
- 'Failed to decrypt state file - wrong encryption key? Starting fresh.';
- this.launchWarnings.push(warning);
- console.error(`[WARN] ${warning}`);
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`[DEBUG] Decryption error:`, decryptErr);
- }
- }
- } else {
- const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`;
- this.launchWarnings.push(warning);
- console.error(`[WARN] ${warning}`);
- }
- } else {
- storageState = options.autoStateFilePath;
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`);
- }
- }
- }
- } catch (err) {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`[DEBUG] Failed to load state file, starting fresh:`, err);
- }
- }
- }
-
- context = await this.browser.newContext({
- viewport,
- extraHTTPHeaders: options.headers,
- userAgent: options.userAgent,
- storageState,
- ...(options.proxy && { proxy: options.proxy }),
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
- });
- }
-
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
-
- const page = context.pages()[0] ?? (await context.newPage());
- await this.sanitizeExistingPages([page]);
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
- if (!this.pages.includes(page)) {
- this.pages.push(page);
- this.setupPageTracking(page);
- }
- this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0;
- }
-
- /**
- * Connect to a running browser via CDP (Chrome DevTools Protocol)
- * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
- */
- private async connectViaCDP(
- cdpEndpoint: string | undefined,
- options?: { timeout?: number }
- ): Promise {
- if (!cdpEndpoint) {
- throw new Error('CDP endpoint is required for CDP connection');
- }
-
- // Determine the connection URL:
- // - If it starts with ws://, wss://, http://, or https://, use it directly
- // - If it's a numeric string (e.g., "9222"), treat as port for localhost
- // - Otherwise, treat it as a port number for localhost
- let cdpUrl: string;
- if (
- cdpEndpoint.startsWith('ws://') ||
- cdpEndpoint.startsWith('wss://') ||
- cdpEndpoint.startsWith('http://') ||
- cdpEndpoint.startsWith('https://')
- ) {
- cdpUrl = cdpEndpoint;
- } else if (/^\d+$/.test(cdpEndpoint)) {
- // Numeric string - treat as port number (handles JSON serialization quirks)
- cdpUrl = `http://127.0.0.1:${cdpEndpoint}`;
- } else {
- // Unknown format - still try as port for backward compatibility
- cdpUrl = `http://127.0.0.1:${cdpEndpoint}`;
- }
-
- const browser = await chromium
- .connectOverCDP(cdpUrl, { timeout: options?.timeout })
- .catch(() => {
- throw new Error(
- `Failed to connect via CDP to ${cdpUrl}. ` +
- (cdpUrl.includes('127.0.0.1')
- ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
- : 'Make sure the remote browser is accessible and the URL is correct.')
- );
- });
-
- // Validate and set up state, cleaning up browser connection if anything fails
- try {
- const contexts = browser.contexts();
- if (contexts.length === 0) {
- throw new Error('No browser context found. Make sure the app has an open window.');
- }
-
- // Filter out pages with empty URLs, which can cause Playwright to hang
- const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
-
- if (allPages.length === 0) {
- throw new Error('No page found. Make sure the app has loaded content.');
- }
-
- // All validation passed - commit state
- this.browser = browser;
- this.cdpEndpoint = cdpEndpoint;
-
- let resolvedWs: string | null = null;
- try {
- resolvedWs = (browser as any).wsEndpoint?.() ?? null;
- } catch (err) {
- console.error('[inspect] wsEndpoint() failed:', err);
- }
- if (!resolvedWs && (cdpUrl.startsWith('http://') || cdpUrl.startsWith('https://'))) {
- try {
- const resp = await fetch(`${cdpUrl}/json/version`);
- const info: any = await resp.json();
- resolvedWs = info.webSocketDebuggerUrl ?? null;
- } catch (err) {
- console.error('[inspect] /json/version fetch failed:', err);
- }
- }
- this.resolvedWsUrl = resolvedWs;
-
- for (const context of contexts) {
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
- }
-
- await this.sanitizeExistingPages(allPages);
-
- for (const page of allPages) {
- this.pages.push(page);
- this.setupPageTracking(page);
- }
-
- this.activePageIndex = 0;
- } catch (error) {
- // Clean up browser connection if validation or setup failed
- await browser.close().catch(() => {});
- throw error;
- }
- }
-
- /**
- * Get Chrome's default user data directory paths for the current platform.
- * Returns an array of candidate paths to check (stable, then beta/canary).
- */
- private getChromeUserDataDirs(): string[] {
- const home = os.homedir();
- const platform = os.platform();
-
- if (platform === 'darwin') {
- return [
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
- path.join(home, 'Library', 'Application Support', 'Chromium'),
- ];
- } else if (platform === 'win32') {
- const localAppData = process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
- return [
- path.join(localAppData, 'Google', 'Chrome', 'User Data'),
- path.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
- path.join(localAppData, 'Chromium', 'User Data'),
- ];
- } else {
- // Linux
- return [
- path.join(home, '.config', 'google-chrome'),
- path.join(home, '.config', 'google-chrome-unstable'),
- path.join(home, '.config', 'chromium'),
- ];
- }
- }
-
- /**
- * Try to read the DevToolsActivePort file from a Chrome user data directory.
- * Returns { port, wsPath } if found, or null if not available.
- */
- private readDevToolsActivePort(userDataDir: string): { port: number; wsPath: string } | null {
- const filePath = path.join(userDataDir, 'DevToolsActivePort');
- try {
- if (!existsSync(filePath)) return null;
- const content = readFileSync(filePath, 'utf-8').trim();
- const lines = content.split('\n');
- if (lines.length < 2) return null;
-
- const port = parseInt(lines[0].trim(), 10);
- const wsPath = lines[1].trim();
-
- if (isNaN(port) || port <= 0 || port > 65535) return null;
- if (!wsPath) return null;
-
- return { port, wsPath };
- } catch {
- return null;
- }
- }
-
- /**
- * Try to discover a Chrome CDP endpoint by querying an HTTP debug port.
- * Returns the WebSocket debugger URL if available.
- */
- private async probeDebugPort(port: number): Promise {
- try {
- const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
- signal: AbortSignal.timeout(2000),
- });
- if (!response.ok) return null;
- const data = (await response.json()) as { webSocketDebuggerUrl?: string };
- return data.webSocketDebuggerUrl ?? null;
- } catch {
- return null;
- }
- }
-
- /**
- * Auto-discover and connect to a running Chrome/Chromium instance.
- *
- * Discovery strategy:
- * 1. Read DevToolsActivePort from Chrome's default user data directories
- * 2. If found, connect using the port and WebSocket path from that file
- * 3. If not found, probe common debugging ports (9222, 9229)
- * 4. If a port responds, connect via CDP
- */
- private async autoConnectViaCDP(): Promise {
- // Strategy 1: Check DevToolsActivePort files
- const userDataDirs = this.getChromeUserDataDirs();
- for (const dir of userDataDirs) {
- const activePort = this.readDevToolsActivePort(dir);
- if (activePort) {
- // Try HTTP discovery first (works with --remote-debugging-port mode)
- const wsUrl = await this.probeDebugPort(activePort.port);
- if (wsUrl) {
- await this.connectViaCDP(wsUrl);
- return;
- }
- // HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a
- // WebSocket-only server with no HTTP endpoints. Connect using the WebSocket
- // path read directly from DevToolsActivePort.
- const directWsUrl = `ws://127.0.0.1:${activePort.port}${activePort.wsPath}`;
- try {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(
- `[DEBUG] HTTP probe failed on port ${activePort.port}, ` +
- `attempting direct WebSocket connection to ${directWsUrl}`
- );
- }
- await this.connectViaCDP(directWsUrl, { timeout: 60_000 });
- return;
- } catch {
- // Direct WebSocket also failed, try next directory
- }
- }
- }
-
- // Strategy 2: Probe common debugging ports
- const commonPorts = [9222, 9229];
- for (const port of commonPorts) {
- const wsUrl = await this.probeDebugPort(port);
- if (wsUrl) {
- await this.connectViaCDP(wsUrl);
- return;
- }
- }
-
- // Nothing found
- const platform = os.platform();
- let hint: string;
- if (platform === 'darwin') {
- hint =
- 'Start Chrome with: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' +
- 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
- } else if (platform === 'win32') {
- hint =
- 'Start Chrome with: chrome.exe --remote-debugging-port=9222\n' +
- 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
- } else {
- hint =
- 'Start Chrome with: google-chrome --remote-debugging-port=9222\n' +
- 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
- }
-
- throw new Error(`No running Chrome instance with remote debugging found.\n${hint}`);
- }
-
- /**
- * Set up console, error, and close tracking for a page
- */
- private setupPageTracking(page: Page): void {
- if (this.colorScheme) {
- page.emulateMedia({ colorScheme: this.colorScheme }).catch(() => {});
- }
-
- page.on('console', (msg) => {
- this.consoleMessages.push({
- type: msg.type(),
- text: msg.text(),
- timestamp: Date.now(),
- });
- });
-
- page.on('pageerror', (error) => {
- this.pageErrors.push({
- message: error.message,
- timestamp: Date.now(),
- });
- });
-
- page.on('close', () => {
- const index = this.pages.indexOf(page);
- if (index !== -1) {
- this.pages.splice(index, 1);
- if (this.activePageIndex >= this.pages.length) {
- this.activePageIndex = Math.max(0, this.pages.length - 1);
- }
- }
- });
- }
-
- /**
- * Set up tracking for new pages in a context (for CDP connections and popups/new tabs)
- * This handles pages created externally (e.g., via target="_blank" links, window.open)
- */
- private setupContextTracking(context: BrowserContext): void {
- context.on('page', (page) => {
- // Only add if not already tracked (avoids duplicates when newTab() creates pages)
- if (!this.pages.includes(page)) {
- this.pages.push(page);
- this.setupPageTracking(page);
- }
-
- // Auto-switch to the newly opened tab so subsequent commands target it.
- // For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after),
- // but for externally opened tabs (window.open, target="_blank"), this ensures the active tab
- // stays in sync with the browser.
- const newIndex = this.pages.indexOf(page);
- if (newIndex !== -1 && newIndex !== this.activePageIndex) {
- this.activePageIndex = newIndex;
- // Invalidate CDP session since the active page changed
- this.invalidateCDPSession().catch(() => {});
- }
- });
- }
-
- /**
- * Create a new tab in the current context
- */
- async newTab(): Promise<{ index: number; total: number }> {
- if (!this.isLaunched() || this.contexts.length === 0) {
- throw new Error('Browser not launched');
- }
-
- // Invalidate CDP session since we're switching to a new page
- await this.invalidateCDPSession();
-
- const context = this.contexts[0]; // Use first context for tabs
- const page = await context.newPage();
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
- if (!this.pages.includes(page)) {
- this.pages.push(page);
- this.setupPageTracking(page);
- }
- this.activePageIndex = this.pages.length - 1;
-
- return { index: this.activePageIndex, total: this.pages.length };
- }
-
- /**
- * Create a new window (new context)
- */
- async newWindow(viewport?: { width: number; height: number } | null): Promise<{
- index: number;
- total: number;
- }> {
- if (!this.browser) {
- throw new Error(
- this.isPersistentContext
- ? 'newWindow is not supported in extension (persistent context) mode'
- : 'Browser not launched'
- );
- }
-
- const context = await this.browser.newContext({
- viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
- });
- context.setDefaultTimeout(getDefaultTimeout());
- this.contexts.push(context);
- this.setupContextTracking(context);
- await this.ensureDomainFilter(context);
-
- const page = await context.newPage();
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
- if (!this.pages.includes(page)) {
- this.pages.push(page);
- this.setupPageTracking(page);
- }
- this.activePageIndex = this.pages.length - 1;
-
- return { index: this.activePageIndex, total: this.pages.length };
- }
-
- /**
- * Invalidate the current CDP session (must be called before switching pages)
- * This ensures screencast and input injection work correctly after tab switch
- */
- private async invalidateCDPSession(): Promise {
- // Stop screencast if active (it's tied to the current page's CDP session)
- if (this.screencastActive) {
- await this.stopScreencast();
- }
-
- // Detach and clear the CDP session
- if (this.cdpSession) {
- await this.cdpSession.detach().catch(() => {});
- this.cdpSession = null;
- }
- }
-
- /**
- * Switch to a specific tab/page by index
- */
- async switchTo(index: number): Promise<{ index: number; url: string; title: string }> {
- if (index < 0 || index >= this.pages.length) {
- throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
- }
-
- // Invalidate CDP session before switching (it's page-specific)
- if (index !== this.activePageIndex) {
- await this.invalidateCDPSession();
- }
-
- this.activePageIndex = index;
- const page = this.pages[index];
-
- return {
- index: this.activePageIndex,
- url: page.url(),
- title: '', // Title requires async, will be fetched separately
- };
- }
-
- /**
- * Close a specific tab/page
- */
- async closeTab(index?: number): Promise<{ closed: number; remaining: number }> {
- const targetIndex = index ?? this.activePageIndex;
-
- if (targetIndex < 0 || targetIndex >= this.pages.length) {
- throw new Error(`Invalid tab index: ${targetIndex}`);
- }
-
- if (this.pages.length === 1) {
- throw new Error('Cannot close the last tab. Use "close" to close the browser.');
- }
-
- // If closing the active tab, invalidate CDP session first
- if (targetIndex === this.activePageIndex) {
- await this.invalidateCDPSession();
- }
-
- const page = this.pages[targetIndex];
- await page.close();
- this.pages.splice(targetIndex, 1);
-
- // Adjust active index if needed
- if (this.activePageIndex >= this.pages.length) {
- this.activePageIndex = this.pages.length - 1;
- } else if (this.activePageIndex > targetIndex) {
- this.activePageIndex--;
- }
-
- return { closed: targetIndex, remaining: this.pages.length };
- }
-
- /**
- * List all tabs with their info
- */
- async listTabs(): Promise> {
- const tabs = await Promise.all(
- this.pages.map(async (page, index) => ({
- index,
- url: page.url(),
- title: await page.title().catch(() => ''),
- active: index === this.activePageIndex,
- }))
- );
- return tabs;
- }
-
- /**
- * Get or create a CDP session for the current page
- * Only works with Chromium-based browsers
- */
- async getCDPSession(): Promise {
- if (this.cdpSession) {
- return this.cdpSession;
- }
-
- const page = this.getPage();
- const context = page.context();
-
- // Create a new CDP session attached to the page
- this.cdpSession = await context.newCDPSession(page);
- return this.cdpSession;
- }
-
- /**
- * Check if screencast is currently active
- */
- isScreencasting(): boolean {
- return this.screencastActive;
- }
-
- /**
- * Start screencast - streams viewport frames via CDP
- * @param callback Function called for each frame
- * @param options Screencast options
- */
- async startScreencast(
- callback: (frame: ScreencastFrame) => void,
- options?: ScreencastOptions
- ): Promise {
- if (this.screencastActive) {
- throw new Error('Screencast already active');
- }
-
- const cdp = await this.getCDPSession();
- this.frameCallback = callback;
- this.screencastActive = true;
-
- // Create and store the frame handler so we can remove it later
- this.screencastFrameHandler = async (params: any) => {
- const frame: ScreencastFrame = {
- data: params.data,
- metadata: params.metadata,
- sessionId: params.sessionId,
- };
-
- // Acknowledge the frame to receive the next one
- await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
-
- // Call the callback with the frame
- if (this.frameCallback) {
- this.frameCallback(frame);
- }
- };
-
- // Listen for screencast frames
- cdp.on('Page.screencastFrame', this.screencastFrameHandler);
-
- // Start the screencast
- await cdp.send('Page.startScreencast', {
- format: options?.format ?? 'jpeg',
- quality: options?.quality ?? 80,
- maxWidth: options?.maxWidth ?? 1280,
- maxHeight: options?.maxHeight ?? 720,
- everyNthFrame: options?.everyNthFrame ?? 1,
- });
- }
-
- /**
- * Stop screencast
- */
- async stopScreencast(): Promise {
- if (!this.screencastActive) {
- return;
- }
-
- try {
- const cdp = await this.getCDPSession();
- await cdp.send('Page.stopScreencast');
-
- // Remove the event listener to prevent accumulation
- if (this.screencastFrameHandler) {
- cdp.off('Page.screencastFrame', this.screencastFrameHandler);
- }
- } catch {
- // Ignore errors when stopping
- }
-
- this.screencastActive = false;
- this.frameCallback = null;
- this.screencastFrameHandler = null;
- }
-
- /**
- * Check if profiling is currently active
- */
- isProfilingActive(): boolean {
- return this.profilingActive;
- }
-
- /**
- * Start CDP profiling (Tracing)
- */
- async startProfiling(options?: { categories?: string[] }): Promise {
- if (this.profilingActive) {
- throw new Error('Profiling already active');
- }
-
- const cdp = await this.getCDPSession();
-
- const dataHandler = (params: { value?: TraceEvent[] }) => {
- if (params.value) {
- for (const evt of params.value) {
- if (this.profileChunks.length >= BrowserManager.MAX_PROFILE_EVENTS) {
- if (!this.profileEventsDropped) {
- this.profileEventsDropped = true;
- console.warn(
- `Profiling: exceeded ${BrowserManager.MAX_PROFILE_EVENTS} events, dropping further data`
- );
- }
- return;
- }
- this.profileChunks.push(evt);
- }
- }
- };
-
- const completeHandler = () => {
- if (this.profileCompleteResolver) {
- this.profileCompleteResolver();
- }
- };
-
- cdp.on('Tracing.dataCollected', dataHandler);
- cdp.on('Tracing.tracingComplete', completeHandler);
-
- const categories = options?.categories ?? [
- 'devtools.timeline',
- 'disabled-by-default-devtools.timeline',
- 'disabled-by-default-devtools.timeline.frame',
- 'disabled-by-default-devtools.timeline.stack',
- 'v8.execute',
- 'disabled-by-default-v8.cpu_profiler',
- 'disabled-by-default-v8.cpu_profiler.hires',
- 'v8',
- 'disabled-by-default-v8.runtime_stats',
- 'blink',
- 'blink.user_timing',
- 'latencyInfo',
- 'renderer.scheduler',
- 'sequence_manager',
- 'toplevel',
- ];
-
- try {
- await cdp.send('Tracing.start', {
- traceConfig: {
- includedCategories: categories,
- enableSampling: true,
- },
- transferMode: 'ReportEvents',
- });
- } catch (error) {
- cdp.off('Tracing.dataCollected', dataHandler);
- cdp.off('Tracing.tracingComplete', completeHandler);
- throw error;
- }
-
- // Only commit state after the CDP call succeeds
- this.profilingActive = true;
- this.profileChunks = [];
- this.profileEventsDropped = false;
- this.profileDataHandler = dataHandler;
- this.profileCompleteHandler = completeHandler;
- }
-
- /**
- * Stop CDP profiling and save to file
- */
- async stopProfiling(outputPath: string): Promise<{ path: string; eventCount: number }> {
- if (!this.profilingActive) {
- throw new Error('No profiling session active');
- }
-
- const cdp = await this.getCDPSession();
-
- const TRACE_TIMEOUT_MS = 30_000;
- const completePromise = new Promise((resolve, reject) => {
- const timer = setTimeout(
- () => reject(new Error('Profiling data collection timed out')),
- TRACE_TIMEOUT_MS
- );
- this.profileCompleteResolver = () => {
- clearTimeout(timer);
- resolve();
- };
- });
-
- await cdp.send('Tracing.end');
-
- let chunks: TraceEvent[];
- try {
- await completePromise;
- chunks = this.profileChunks;
- } finally {
- if (this.profileDataHandler) {
- cdp.off('Tracing.dataCollected', this.profileDataHandler);
- }
- if (this.profileCompleteHandler) {
- cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
- }
- this.profilingActive = false;
- this.profileChunks = [];
- this.profileEventsDropped = false;
- this.profileCompleteResolver = null;
- this.profileDataHandler = null;
- this.profileCompleteHandler = null;
- }
-
- const clockDomain =
- process.platform === 'linux'
- ? 'LINUX_CLOCK_MONOTONIC'
- : process.platform === 'darwin'
- ? 'MAC_MACH_ABSOLUTE_TIME'
- : undefined;
-
- const traceData: Record = {
- traceEvents: chunks,
- };
- if (clockDomain) {
- traceData.metadata = { 'clock-domain': clockDomain };
- }
-
- const dir = path.dirname(outputPath);
- await mkdir(dir, { recursive: true });
-
- await writeFile(outputPath, JSON.stringify(traceData));
-
- const eventCount = chunks.length;
-
- return { path: outputPath, eventCount };
- }
-
- /**
- * Inject a mouse event via CDP
- */
- async injectMouseEvent(params: {
- type: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';
- x: number;
- y: number;
- button?: 'left' | 'right' | 'middle' | 'none';
- clickCount?: number;
- deltaX?: number;
- deltaY?: number;
- modifiers?: number; // 1=Alt, 2=Ctrl, 4=Meta, 8=Shift
- }): Promise {
- const cdp = await this.getCDPSession();
-
- const cdpButton =
- params.button === 'left'
- ? 'left'
- : params.button === 'right'
- ? 'right'
- : params.button === 'middle'
- ? 'middle'
- : 'none';
-
- await cdp.send('Input.dispatchMouseEvent', {
- type: params.type,
- x: params.x,
- y: params.y,
- button: cdpButton,
- clickCount: params.clickCount ?? 1,
- deltaX: params.deltaX ?? 0,
- deltaY: params.deltaY ?? 0,
- modifiers: params.modifiers ?? 0,
- });
- }
-
- /**
- * Inject a keyboard event via CDP
- */
- async injectKeyboardEvent(params: {
- type: 'keyDown' | 'keyUp' | 'char';
- key?: string;
- code?: string;
- text?: string;
- modifiers?: number; // 1=Alt, 2=Ctrl, 4=Meta, 8=Shift
- }): Promise {
- const cdp = await this.getCDPSession();
-
- await cdp.send('Input.dispatchKeyEvent', {
- type: params.type,
- key: params.key,
- code: params.code,
- text: params.text,
- modifiers: params.modifiers ?? 0,
- });
- }
-
- /**
- * Inject touch event via CDP (for mobile emulation)
- */
- async injectTouchEvent(params: {
- type: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel';
- touchPoints: Array<{ x: number; y: number; id?: number }>;
- modifiers?: number;
- }): Promise {
- const cdp = await this.getCDPSession();
-
- await cdp.send('Input.dispatchTouchEvent', {
- type: params.type,
- touchPoints: params.touchPoints.map((tp, i) => ({
- x: tp.x,
- y: tp.y,
- id: tp.id ?? i,
- })),
- modifiers: params.modifiers ?? 0,
- });
- }
-
- /**
- * Check if video recording is currently active
- */
- isRecording(): boolean {
- return this.recordingContext !== null;
- }
-
- /**
- * Start recording to a video file using Playwright's native video recording.
- * Creates a fresh browser context with video recording enabled.
- * Automatically captures current URL and transfers cookies/storage if no URL provided.
- *
- * @param outputPath - Path to the output video file (will be .webm)
- * @param url - Optional URL to navigate to (defaults to current page URL)
- */
- async startRecording(outputPath: string, url?: string): Promise {
- if (this.recordingContext) {
- throw new Error(
- "Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording."
- );
- }
-
- if (!this.browser) {
- throw new Error('Browser not launched. Call launch first.');
- }
-
- // Check if output file already exists
- if (existsSync(outputPath)) {
- throw new Error(`Output file already exists: ${outputPath}`);
- }
-
- // Validate output path is .webm (Playwright native format)
- if (!outputPath.endsWith('.webm')) {
- throw new Error(
- 'Playwright native recording only supports WebM format. Please use a .webm extension.'
- );
- }
-
- // Auto-capture current URL if none provided
- const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
- const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
- if (!url && currentPage) {
- const currentUrl = currentPage.url();
- if (currentUrl && currentUrl !== 'about:blank') {
- url = currentUrl;
- }
- }
-
- // Capture state from current context (cookies + storage)
- let storageState:
- | {
- cookies: Array<{
- name: string;
- value: string;
- domain: string;
- path: string;
- expires: number;
- httpOnly: boolean;
- secure: boolean;
- sameSite: 'Strict' | 'Lax' | 'None';
- }>;
- origins: Array<{
- origin: string;
- localStorage: Array<{ name: string; value: string }>;
- }>;
- }
- | undefined;
-
- if (currentContext) {
- try {
- storageState = await currentContext.storageState();
- } catch {
- // Ignore errors - context might be closed or invalid
- }
- }
-
- // Create a temp directory for video recording
- const session = process.env.AGENT_BROWSER_SESSION || 'default';
- this.recordingTempDir = path.join(
- os.tmpdir(),
- `agent-browser-recording-${session}-${Date.now()}`
- );
- mkdirSync(this.recordingTempDir, { recursive: true });
-
- this.recordingOutputPath = outputPath;
-
- // Reuse the active page viewport when available so recording matches the current layout.
- const viewport = currentPage?.viewportSize() ?? { width: 1280, height: 720 };
- this.recordingContext = await this.browser.newContext({
- viewport,
- recordVideo: {
- dir: this.recordingTempDir,
- size: viewport,
- },
- storageState,
- });
- this.recordingContext.setDefaultTimeout(10000);
-
- // Create a page in the recording context
- this.recordingPage = await this.recordingContext.newPage();
-
- // Add the recording context and page to our managed lists
- this.contexts.push(this.recordingContext);
- this.pages.push(this.recordingPage);
- this.activePageIndex = this.pages.length - 1;
-
- // Set up page tracking
- this.setupPageTracking(this.recordingPage);
-
- // Invalidate CDP session since we switched pages
- await this.invalidateCDPSession();
-
- // Navigate to URL if provided or captured
- if (url) {
- await this.recordingPage.goto(url, { waitUntil: 'load' });
- }
- }
-
- /**
- * Stop recording and save the video file
- * @returns Recording result with path
- */
- async stopRecording(): Promise<{ path: string; frames: number; error?: string }> {
- if (!this.recordingContext || !this.recordingPage) {
- return { path: '', frames: 0, error: 'No recording in progress' };
- }
-
- const outputPath = this.recordingOutputPath;
-
- try {
- // Get the video object before closing the page
- const video = this.recordingPage.video();
-
- // Remove recording page/context from our managed lists before closing
- const pageIndex = this.pages.indexOf(this.recordingPage);
- if (pageIndex !== -1) {
- this.pages.splice(pageIndex, 1);
- }
- const contextIndex = this.contexts.indexOf(this.recordingContext);
- if (contextIndex !== -1) {
- this.contexts.splice(contextIndex, 1);
- }
-
- // Close the page to finalize the video
- await this.recordingPage.close();
-
- // Save the video to the desired output path
- if (video) {
- await video.saveAs(outputPath);
- }
-
- // Clean up temp directory
- if (this.recordingTempDir) {
- rmSync(this.recordingTempDir, { recursive: true, force: true });
- }
-
- // Close the recording context
- await this.recordingContext.close();
-
- // Reset recording state
- this.recordingContext = null;
- this.recordingPage = null;
- this.recordingOutputPath = '';
- this.recordingTempDir = '';
-
- // Adjust active page index
- if (this.pages.length > 0) {
- this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
- } else {
- this.activePageIndex = 0;
- }
-
- // Invalidate CDP session since we may have switched pages
- await this.invalidateCDPSession();
-
- return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
- } catch (error) {
- // Clean up temp directory on error
- if (this.recordingTempDir) {
- rmSync(this.recordingTempDir, { recursive: true, force: true });
- }
-
- // Reset state on error
- this.recordingContext = null;
- this.recordingPage = null;
- this.recordingOutputPath = '';
- this.recordingTempDir = '';
-
- const message = error instanceof Error ? error.message : String(error);
- return { path: outputPath, frames: 0, error: message };
- }
- }
-
- /**
- * Restart recording - stops current recording (if any) and starts a new one.
- * Convenience method that combines stopRecording and startRecording.
- *
- * @param outputPath - Path to the output video file (must be .webm)
- * @param url - Optional URL to navigate to (defaults to current page URL)
- * @returns Result from stopping the previous recording (if any)
- */
- async restartRecording(
- outputPath: string,
- url?: string
- ): Promise<{ previousPath?: string; stopped: boolean }> {
- let previousPath: string | undefined;
- let stopped = false;
-
- // Stop current recording if active
- if (this.recordingContext) {
- const result = await this.stopRecording();
- previousPath = result.path;
- stopped = true;
- }
-
- // Start new recording
- await this.startRecording(outputPath, url);
-
- return { previousPath, stopped };
- }
-
- /**
- * Close the browser and clean up
- */
- async close(): Promise {
- this.stopInspectServer();
-
- // Stop recording if active (saves video)
- if (this.recordingContext) {
- await this.stopRecording();
- }
-
- // Stop screencast if active
- if (this.screencastActive) {
- await this.stopScreencast();
- }
-
- // Clean up profiling state if active (without saving)
- if (this.profilingActive) {
- const cdp = this.cdpSession;
- if (cdp) {
- if (this.profileDataHandler) {
- cdp.off('Tracing.dataCollected', this.profileDataHandler);
- }
- if (this.profileCompleteHandler) {
- cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
- }
- await cdp.send('Tracing.end').catch(() => {});
- }
- this.profilingActive = false;
- this.profileChunks = [];
- this.profileEventsDropped = false;
- this.profileCompleteResolver = null;
- this.profileDataHandler = null;
- this.profileCompleteHandler = null;
- }
-
- // Clean up CDP session
- if (this.cdpSession) {
- await this.cdpSession.detach().catch(() => {});
- this.cdpSession = null;
- }
-
- if (this.browserbaseSessionId && this.browserbaseApiKey) {
- await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch(
- (error) => {
- console.error('Failed to close Browserbase session:', error);
- }
- );
- this.browser = null;
- } else if (this.browserUseSessionId && this.browserUseApiKey) {
- await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch(
- (error) => {
- console.error('Failed to close Browser Use session:', error);
- }
- );
- this.browser = null;
- } else if (this.kernelSessionId) {
- await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey ?? undefined).catch(
- (error) => {
- console.error('Failed to close Kernel session:', error);
- }
- );
- this.browser = null;
- } else if (this.browserlessStopUrl) {
- await this.closeBrowserlessSession(this.browserlessStopUrl).catch((error) => {
- console.error('Failed to close Browserless session:', error);
- });
- this.browser = null;
- } else if (this.cdpEndpoint !== null) {
- // CDP: only disconnect, don't close external app's pages
- if (this.browser) {
- await this.browser.close().catch(() => {});
- this.browser = null;
- }
- } else {
- // Regular browser: close everything
- for (const page of this.pages) {
- await page.close().catch(() => {});
- }
- for (const context of this.contexts) {
- await context.close().catch(() => {});
- }
- if (this.browser) {
- await this.browser.close().catch(() => {});
- this.browser = null;
- }
- }
-
- this.pages = [];
- this.contexts = [];
- this.cdpEndpoint = null;
- this.resolvedWsUrl = null;
- this.browserbaseSessionId = null;
- this.browserbaseApiKey = null;
- this.browserUseSessionId = null;
- this.browserUseApiKey = null;
- this.kernelSessionId = null;
- this.kernelApiKey = null;
- this.browserlessStopUrl = null;
- this.isPersistentContext = false;
- this.activePageIndex = 0;
- this.colorScheme = null;
- this.refMap = {};
- this.lastSnapshot = '';
- this.frameCallback = null;
- }
-}
diff --git a/src/confirmation.test.ts b/src/confirmation.test.ts
deleted file mode 100644
index f2553ab..0000000
--- a/src/confirmation.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { requestConfirmation, getAndRemovePending } from './confirmation.js';
-
-describe('confirmation', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- describe('requestConfirmation', () => {
- it('should return a confirmation ID', () => {
- const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' });
- expect(result.confirmationId).toBeTruthy();
- expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/);
- });
-
- it('should generate unique IDs', () => {
- const r1 = requestConfirmation('evaluate', 'eval', 'desc', {});
- const r2 = requestConfirmation('click', 'click', 'desc', {});
- expect(r1.confirmationId).not.toBe(r2.confirmationId);
- });
- });
-
- describe('getAndRemovePending', () => {
- it('should retrieve and remove a pending confirmation', () => {
- const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {
- action: 'evaluate',
- script: 'test',
- });
-
- const entry = getAndRemovePending(confirmationId);
- expect(entry).not.toBeNull();
- expect(entry!.action).toBe('evaluate');
- expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' });
- });
-
- it('should return null on second retrieval (already removed)', () => {
- const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
- getAndRemovePending(confirmationId);
- expect(getAndRemovePending(confirmationId)).toBeNull();
- });
-
- it('should return null for non-existent ID', () => {
- expect(getAndRemovePending('c_nonexistent')).toBeNull();
- });
-
- it('should auto-deny after 60 seconds', () => {
- const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
-
- vi.advanceTimersByTime(60_000);
-
- expect(getAndRemovePending(confirmationId)).toBeNull();
- });
-
- it('should still be retrievable before 60 second timeout', () => {
- const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
-
- vi.advanceTimersByTime(59_999);
-
- const entry = getAndRemovePending(confirmationId);
- expect(entry).not.toBeNull();
- });
- });
-});
diff --git a/src/confirmation.ts b/src/confirmation.ts
deleted file mode 100644
index 04b00cf..0000000
--- a/src/confirmation.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { randomBytes } from 'node:crypto';
-
-interface PendingConfirmation {
- id: string;
- action: string;
- category: string;
- description: string;
- command: Record;
- timer: ReturnType;
-}
-
-const AUTO_DENY_TIMEOUT_MS = 60_000;
-
-const pending = new Map();
-
-function generateId(): string {
- return `c_${randomBytes(8).toString('hex')}`;
-}
-
-export function requestConfirmation(
- action: string,
- category: string,
- description: string,
- command: Record
-): { confirmationId: string } {
- const id = generateId();
-
- const timer = setTimeout(() => {
- pending.delete(id);
- }, AUTO_DENY_TIMEOUT_MS);
-
- pending.set(id, {
- id,
- action,
- category,
- description,
- command,
- timer,
- });
-
- return { confirmationId: id };
-}
-
-export function getAndRemovePending(
- id: string
-): { command: Record; action: string } | null {
- const entry = pending.get(id);
- if (!entry) return null;
-
- clearTimeout(entry.timer);
- pending.delete(id);
- return { command: entry.command, action: entry.action };
-}
diff --git a/src/daemon.test.ts b/src/daemon.test.ts
deleted file mode 100644
index 7ffcd1d..0000000
--- a/src/daemon.test.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import * as os from 'os';
-import * as path from 'path';
-import * as net from 'net';
-import { EventEmitter } from 'events';
-import { getSocketDir, safeWrite, getPortForSession } from './daemon.js';
-
-/**
- * HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
- * This pattern detects HTTP method prefixes that browsers must send when using fetch().
- */
-const HTTP_REQUEST_PATTERN = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i;
-
-describe('HTTP request detection (security)', () => {
- it('should detect POST requests from fetch()', () => {
- const httpRequest = 'POST / HTTP/1.1\r\nHost: 127.0.0.1:51234\r\n';
- expect(HTTP_REQUEST_PATTERN.test(httpRequest.trimStart())).toBe(true);
- });
-
- it('should detect GET requests', () => {
- expect(HTTP_REQUEST_PATTERN.test('GET / HTTP/1.1')).toBe(true);
- });
-
- it('should detect OPTIONS preflight requests', () => {
- expect(HTTP_REQUEST_PATTERN.test('OPTIONS / HTTP/1.1')).toBe(true);
- });
-
- it('should NOT detect valid JSON commands', () => {
- const jsonCommand = '{"id":"1","action":"navigate","url":"https://example.com"}';
- expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
- });
-
- it('should NOT detect JSON with leading whitespace', () => {
- const jsonCommand = ' {"id":"1","action":"click","selector":"button"}';
- expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
- });
-
- it('should be case insensitive for HTTP methods', () => {
- expect(HTTP_REQUEST_PATTERN.test('post / HTTP/1.1')).toBe(true);
- expect(HTTP_REQUEST_PATTERN.test('Post / HTTP/1.1')).toBe(true);
- });
-});
-
-describe('getSocketDir', () => {
- const originalEnv = { ...process.env };
-
- beforeEach(() => {
- // Clear relevant env vars before each test
- delete process.env.AGENT_BROWSER_SOCKET_DIR;
- delete process.env.XDG_RUNTIME_DIR;
- });
-
- afterEach(() => {
- // Restore original env
- process.env = { ...originalEnv };
- });
-
- describe('AGENT_BROWSER_SOCKET_DIR', () => {
- it('should use custom path when set', () => {
- process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/socket/path';
- expect(getSocketDir()).toBe('/custom/socket/path');
- });
-
- it('should ignore empty string', () => {
- process.env.AGENT_BROWSER_SOCKET_DIR = '';
- const result = getSocketDir();
- expect(result).toContain('.agent-browser');
- });
-
- it('should take priority over XDG_RUNTIME_DIR', () => {
- process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/path';
- process.env.XDG_RUNTIME_DIR = '/run/user/1000';
- expect(getSocketDir()).toBe('/custom/path');
- });
- });
-
- describe('XDG_RUNTIME_DIR', () => {
- it('should use when AGENT_BROWSER_SOCKET_DIR is not set', () => {
- process.env.XDG_RUNTIME_DIR = '/run/user/1000';
- expect(getSocketDir()).toBe('/run/user/1000/agent-browser');
- });
-
- it('should ignore empty string', () => {
- process.env.AGENT_BROWSER_SOCKET_DIR = '';
- process.env.XDG_RUNTIME_DIR = '';
- const result = getSocketDir();
- expect(result).toContain('.agent-browser');
- });
- });
-
- describe('fallback', () => {
- it('should use home directory when env vars are not set', () => {
- const result = getSocketDir();
- const expected = path.join(os.homedir(), '.agent-browser');
- expect(result).toBe(expected);
- });
- });
-});
-
-function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
- const emitter = new EventEmitter();
- const socket = Object.assign(emitter, {
- destroyed: opts.destroyed ?? false,
- write: vi.fn().mockReturnValue(opts.writeReturns ?? true),
- removeListener: emitter.removeListener.bind(emitter),
- });
- return socket as unknown as net.Socket;
-}
-
-describe('safeWrite', () => {
- it('should resolve immediately when socket.write returns true', async () => {
- const socket = createMockSocket({ writeReturns: true });
- await safeWrite(socket, 'hello\n');
- expect(socket.write).toHaveBeenCalledWith('hello\n');
- });
-
- it('should resolve immediately when socket is already destroyed', async () => {
- const socket = createMockSocket({ destroyed: true });
- await safeWrite(socket, 'hello\n');
- expect(socket.write).not.toHaveBeenCalled();
- });
-
- it('should wait for drain event when socket.write returns false', async () => {
- const socket = createMockSocket({ writeReturns: false });
- const promise = safeWrite(socket, 'big payload');
-
- // Simulate drain after a tick
- setTimeout(() => socket.emit('drain'), 0);
- await promise;
-
- expect(socket.write).toHaveBeenCalledWith('big payload');
- });
-
- it('should reject on socket error while waiting for drain', async () => {
- const socket = createMockSocket({ writeReturns: false });
- const promise = safeWrite(socket, 'data');
-
- setTimeout(() => socket.emit('error', new Error('connection reset')), 0);
- await expect(promise).rejects.toThrow('connection reset');
- });
-
- it('should resolve on socket close while waiting for drain', async () => {
- const socket = createMockSocket({ writeReturns: false });
- const promise = safeWrite(socket, 'data');
-
- setTimeout(() => socket.emit('close'), 0);
- await promise;
- });
-
- it('should clean up listeners after drain resolves', async () => {
- const socket = createMockSocket({ writeReturns: false });
- const promise = safeWrite(socket, 'data');
-
- setTimeout(() => socket.emit('drain'), 0);
- await promise;
-
- expect(socket.listenerCount('drain')).toBe(0);
- expect(socket.listenerCount('error')).toBe(0);
- expect(socket.listenerCount('close')).toBe(0);
- });
-});
-
-describe('getPortForSession', () => {
- it('returns consistent port for "default"', () => {
- expect(getPortForSession('default')).toBe(50838);
- });
-
- it('returns consistent port for named sessions', () => {
- expect(getPortForSession('my-session')).toBe(63105);
- expect(getPortForSession('work')).toBe(51184);
- });
-
- it('returns base port for empty session', () => {
- expect(getPortForSession('')).toBe(49152);
- });
-
- it('returns port within dynamic range (49152-65535)', () => {
- for (const name of ['default', 'my-session', 'work', 'test', 'a']) {
- const port = getPortForSession(name);
- expect(port).toBeGreaterThanOrEqual(49152);
- expect(port).toBeLessThanOrEqual(65535);
- }
- });
-});
diff --git a/src/daemon.ts b/src/daemon.ts
deleted file mode 100644
index 8d7e513..0000000
--- a/src/daemon.ts
+++ /dev/null
@@ -1,772 +0,0 @@
-import * as net from 'net';
-import * as fs from 'fs';
-import * as path from 'path';
-import * as os from 'os';
-import { BrowserManager } from './browser.js';
-import { IOSManager } from './ios-manager.js';
-import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
-import { executeCommand, initActionPolicy } from './actions.js';
-import { executeIOSCommand } from './ios-actions.js';
-import { StreamServer } from './stream-server.js';
-import {
- getSessionsDir,
- ensureSessionsDir,
- getEncryptionKey,
- encryptData,
- isValidSessionName,
- cleanupExpiredStates,
- getAutoStateFilePath,
-} from './state-utils.js';
-
-// Manager type - either desktop browser or iOS
-type Manager = BrowserManager | IOSManager;
-
-/**
- * Backpressure-aware socket write.
- * If the kernel buffer is full (socket.write returns false),
- * waits for the 'drain' event before resolving.
- */
-export function safeWrite(socket: net.Socket, payload: string): Promise {
- return new Promise((resolve, reject) => {
- if (socket.destroyed) {
- resolve();
- return;
- }
- const canContinue = socket.write(payload);
- if (canContinue) {
- resolve();
- } else if (socket.destroyed) {
- resolve();
- } else {
- const cleanup = () => {
- socket.removeListener('drain', onDrain);
- socket.removeListener('error', onError);
- socket.removeListener('close', onClose);
- };
- const onDrain = () => {
- cleanup();
- resolve();
- };
- const onError = (err: Error) => {
- cleanup();
- reject(err);
- };
- const onClose = () => {
- cleanup();
- resolve();
- };
- socket.once('drain', onDrain);
- socket.once('error', onError);
- socket.once('close', onClose);
- }
- });
-}
-
-// Platform detection
-const isWindows = process.platform === 'win32';
-
-// Session support - each session gets its own socket/pid
-let currentSession = process.env.AGENT_BROWSER_SESSION || 'default';
-
-// Stream server for browser preview
-let streamServer: StreamServer | null = null;
-
-// Idle timeout - shut down daemon after period of inactivity
-// Configurable via AGENT_BROWSER_IDLE_TIMEOUT_MS env var (default: 15 minutes, 0 to disable)
-const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
-const IDLE_TIMEOUT_MS = (() => {
- const env = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS;
- if (env !== undefined) {
- const val = parseInt(env, 10);
- return isNaN(val) ? DEFAULT_IDLE_TIMEOUT_MS : val;
- }
- return DEFAULT_IDLE_TIMEOUT_MS;
-})();
-let idleTimer: ReturnType | null = null;
-
-// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
-const DEFAULT_STREAM_PORT = 9223;
-
-/**
- * Save state to file with optional encryption.
- */
-async function saveStateToFile(
- browser: BrowserManager,
- filepath: string
-): Promise<{ encrypted: boolean }> {
- const context = browser.getContext();
- if (!context) {
- throw new Error('No browser context available');
- }
-
- const state = await context.storageState();
- const jsonData = JSON.stringify(state, null, 2);
-
- const key = getEncryptionKey();
- if (key) {
- const encrypted = encryptData(jsonData, key);
- fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
- return { encrypted: true };
- }
-
- fs.writeFileSync(filepath, jsonData);
- return { encrypted: false };
-}
-
-const AUTO_EXPIRE_ENV = 'AGENT_BROWSER_STATE_EXPIRE_DAYS';
-const DEFAULT_EXPIRE_DAYS = 30;
-
-function runCleanupExpiredStates(): void {
- const expireDaysStr = process.env[AUTO_EXPIRE_ENV];
- const expireDays = expireDaysStr ? parseInt(expireDaysStr, 10) : DEFAULT_EXPIRE_DAYS;
-
- if (isNaN(expireDays) || expireDays <= 0) {
- return;
- }
-
- try {
- const deleted = cleanupExpiredStates(expireDays);
- if (deleted.length > 0 && process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(
- `[DEBUG] Auto-expired ${deleted.length} state file(s) older than ${expireDays} days`
- );
- }
- } catch (err) {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`[DEBUG] Failed to clean up expired states:`, err);
- }
- }
-}
-
-/**
- * Get the validated session name and auto-state file path.
- * Centralizes session name validation to prevent path traversal.
- */
-function getSessionAutoStatePath(): string | undefined {
- const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
- if (!sessionNameRaw) return undefined;
-
- if (!isValidSessionName(sessionNameRaw)) {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`[SECURITY] Invalid session name rejected: ${sessionNameRaw}`);
- }
- return undefined;
- }
-
- const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
- try {
- const autoStatePath = getAutoStateFilePath(sessionNameRaw, sessionId);
- return autoStatePath && fs.existsSync(autoStatePath) ? autoStatePath : undefined;
- } catch {
- return undefined;
- }
-}
-
-/**
- * Get the auto-state file path for saving (creates sessions dir if needed).
- * Returns undefined if no valid session name is configured.
- */
-function getSessionSaveStatePath(): string | undefined {
- const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
- if (!sessionNameRaw) return undefined;
-
- if (!isValidSessionName(sessionNameRaw)) return undefined;
-
- const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
- try {
- return getAutoStateFilePath(sessionNameRaw, sessionId) ?? undefined;
- } catch {
- return undefined;
- }
-}
-
-/**
- * Set the current session
- */
-export function setSession(session: string): void {
- currentSession = session;
-}
-
-/**
- * Get the current session
- */
-export function getSession(): string {
- return currentSession;
-}
-
-/**
- * Get port number for TCP mode (Windows)
- * Uses a hash of the session name to get a consistent port
- */
-export function getPortForSession(session: string): number {
- let hash = 0;
- for (let i = 0; i < session.length; i++) {
- hash = (hash << 5) - hash + session.charCodeAt(i);
- hash |= 0;
- }
- // Port range 49152-65535 (dynamic/private ports)
- return 49152 + (Math.abs(hash) % 16383);
-}
-
-/**
- * Get the base directory for socket/pid files.
- * Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
- */
-export function getAppDir(): string {
- // 1. XDG_RUNTIME_DIR (Linux standard)
- if (process.env.XDG_RUNTIME_DIR) {
- return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
- }
-
- // 2. Home directory fallback (like Docker Desktop's ~/.docker/run/)
- const homeDir = os.homedir();
- if (homeDir) {
- return path.join(homeDir, '.agent-browser');
- }
-
- // 3. Last resort: temp dir
- return path.join(os.tmpdir(), 'agent-browser');
-}
-
-export function getSocketDir(): string {
- // Allow explicit override for socket directory
- if (process.env.AGENT_BROWSER_SOCKET_DIR) {
- return process.env.AGENT_BROWSER_SOCKET_DIR;
- }
- return getAppDir();
-}
-
-/**
- * Get the socket path for the current session (Unix) or port (Windows)
- */
-export function getSocketPath(session?: string): string {
- const sess = session ?? currentSession;
- if (isWindows) {
- return String(getPortForSession(sess));
- }
- return path.join(getSocketDir(), `${sess}.sock`);
-}
-
-/**
- * Get the port file path for Windows (stores the port number)
- */
-export function getPortFile(session?: string): string {
- const sess = session ?? currentSession;
- return path.join(getSocketDir(), `${sess}.port`);
-}
-
-/**
- * Get the PID file path for the current session
- */
-export function getPidFile(session?: string): string {
- const sess = session ?? currentSession;
- return path.join(getSocketDir(), `${sess}.pid`);
-}
-
-/**
- * Check if daemon is running for the current session
- */
-export function isDaemonRunning(session?: string): boolean {
- const pidFile = getPidFile(session);
- if (!fs.existsSync(pidFile)) return false;
-
- try {
- const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
- // Check if process exists (works on both Unix and Windows)
- process.kill(pid, 0);
- return true;
- } catch (err: unknown) {
- // EPERM means the process exists but we lack permission to signal it
- // (e.g. caller is inside a macOS sandbox). Only ESRCH means it's gone.
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EPERM') {
- return true;
- }
- // Process doesn't exist, clean up stale files
- cleanupSocket(session);
- return false;
- }
-}
-
-/**
- * Get connection info for the current session
- * Returns { type: 'unix', path: string } or { type: 'tcp', port: number }
- */
-export function getConnectionInfo(
- session?: string
-): { type: 'unix'; path: string } | { type: 'tcp'; port: number } {
- const sess = session ?? currentSession;
- if (isWindows) {
- return { type: 'tcp', port: getPortForSession(sess) };
- }
- return { type: 'unix', path: path.join(getSocketDir(), `${sess}.sock`) };
-}
-
-/**
- * Clean up socket and PID file for the current session
- */
-export function cleanupSocket(session?: string): void {
- const pidFile = getPidFile(session);
- const streamPortFile = getStreamPortFile(session);
- try {
- if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
- if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
- if (isWindows) {
- const portFile = getPortFile(session);
- if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
- } else {
- const socketPath = getSocketPath(session);
- if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
- }
- } catch {
- // Ignore cleanup errors
- }
-}
-
-/**
- * Get the stream port file path
- */
-export function getStreamPortFile(session?: string): string {
- const sess = session ?? currentSession;
- return path.join(getSocketDir(), `${sess}.stream`);
-}
-
-/**
- * Start the daemon server
- * @param options.streamPort Port for WebSocket stream server (0 to disable)
- * @param options.provider Provider type ('ios' for iOS Simulator, undefined for desktop)
- */
-export async function startDaemon(options?: {
- streamPort?: number;
- provider?: string;
-}): Promise {
- // Ensure socket directory exists with restricted permissions (owner-only access)
- const socketDir = getSocketDir();
- if (!fs.existsSync(socketDir)) {
- fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
- }
-
- // Clean up any stale socket
- cleanupSocket();
-
- // Clean up expired state files on startup
- runCleanupExpiredStates();
-
- // Initialize action policy enforcement
- initActionPolicy();
-
- // Determine provider from options or environment
- const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
- const isIOS = provider === 'ios';
-
- // Create appropriate manager
- const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
- let shuttingDown = false;
-
- // Start stream server if port is specified (or use default if env var is set)
- // Note: Stream server only works with BrowserManager (desktop), not iOS
- const streamPort =
- options?.streamPort ??
- (process.env.AGENT_BROWSER_STREAM_PORT
- ? parseInt(process.env.AGENT_BROWSER_STREAM_PORT, 10)
- : 0);
-
- if (streamPort > 0 && !isIOS && manager instanceof BrowserManager) {
- streamServer = new StreamServer(manager, streamPort);
- await streamServer.start();
-
- // Write stream port to file for clients to discover
- const streamPortFile = getStreamPortFile();
- fs.writeFileSync(streamPortFile, streamPort.toString());
- }
-
- // Idle timeout: shut down daemon if no commands arrive within the timeout period.
- // Reset on every incoming command. Set AGENT_BROWSER_IDLE_TIMEOUT_MS=0 to disable.
- let shutdownRef: (() => Promise) | null = null;
-
- function resetIdleTimer(): void {
- if (IDLE_TIMEOUT_MS <= 0) return;
- if (idleTimer) clearTimeout(idleTimer);
- idleTimer = setTimeout(() => {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`[DEBUG] Idle timeout reached (${IDLE_TIMEOUT_MS}ms), shutting down daemon`);
- }
- if (shutdownRef) shutdownRef();
- }, IDLE_TIMEOUT_MS);
- // Don't let the idle timer keep the process alive on its own
- if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
- idleTimer.unref();
- }
- }
-
- // Start the idle timer immediately
- resetIdleTimer();
-
- const server = net.createServer((socket) => {
- let buffer = '';
- let httpChecked = false;
-
- // Command serialization: queue incoming lines and process them one at a time.
- // This prevents concurrent command execution which can cause socket.write
- // buffer contention and EAGAIN errors on the Rust CLI side.
- const commandQueue: string[] = [];
- let processing = false;
-
- async function processQueue(): Promise {
- if (processing) return;
- processing = true;
-
- while (commandQueue.length > 0) {
- const line = commandQueue.shift()!;
- // Reset idle timer on every command
- resetIdleTimer();
-
- try {
- const parseResult = parseCommand(line);
-
- if (!parseResult.success) {
- const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
- await safeWrite(socket, serializeResponse(resp) + '\n');
- continue;
- }
-
- // Handle device_list specially - it works without a session and always uses IOSManager
- if (parseResult.command.action === 'device_list') {
- const iosManager = new IOSManager();
- try {
- const devices = await iosManager.listAllDevices();
- const response = {
- id: parseResult.command.id,
- success: true as const,
- data: { devices },
- };
- await safeWrite(socket, serializeResponse(response) + '\n');
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- await safeWrite(
- socket,
- serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
- );
- }
- continue;
- }
-
- // Auto-launch if not already launched and this isn't a launch/close/state_load command
- if (
- !manager.isLaunched() &&
- parseResult.command.action !== 'launch' &&
- parseResult.command.action !== 'close' &&
- parseResult.command.action !== 'state_load'
- ) {
- if (isIOS && manager instanceof IOSManager) {
- // Auto-launch iOS Safari
- // Check for device in command first (for reused daemons), then fall back to env vars
- const cmd = parseResult.command as { iosDevice?: string };
- const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
- await manager.launch({
- device: iosDevice,
- udid: process.env.AGENT_BROWSER_IOS_UDID,
- });
- } else if (manager instanceof BrowserManager) {
- // Auto-launch desktop browser
- const extensions = process.env.AGENT_BROWSER_EXTENSIONS
- ? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/)
- .map((p) => p.trim())
- .filter(Boolean)
- : undefined;
-
- // Parse args from env (comma or newline separated)
- const argsEnv = process.env.AGENT_BROWSER_ARGS;
- const args = argsEnv
- ? argsEnv
- .split(/[,\n]/)
- .map((a) => a.trim())
- .filter((a) => a.length > 0)
- : undefined;
-
- // Parse proxy from env
- const proxyServer = process.env.AGENT_BROWSER_PROXY;
- const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
- const proxy = proxyServer
- ? {
- server: proxyServer,
- ...(proxyBypass && { bypass: proxyBypass }),
- }
- : undefined;
-
- const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
- const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
- const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
- const colorScheme =
- colorSchemeEnv === 'dark' ||
- colorSchemeEnv === 'light' ||
- colorSchemeEnv === 'no-preference'
- ? colorSchemeEnv
- : undefined;
- await manager.launch({
- headless:
- process.env.AGENT_BROWSER_HEADED !== '1' &&
- process.env.AGENT_BROWSER_HEADED !== 'true',
- executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
- extensions: extensions,
- profile: process.env.AGENT_BROWSER_PROFILE,
- storageState: process.env.AGENT_BROWSER_STATE,
- args,
- userAgent: process.env.AGENT_BROWSER_USER_AGENT,
- proxy,
- ignoreHTTPSErrors: ignoreHTTPSErrors,
- allowFileAccess: allowFileAccess,
- colorScheme,
- autoStateFilePath: getSessionAutoStatePath(),
- });
- }
- }
-
- // Recover from stale state: browser is launched but all pages were closed
- if (
- manager instanceof BrowserManager &&
- manager.isLaunched() &&
- !manager.hasPages() &&
- parseResult.command.action !== 'launch' &&
- parseResult.command.action !== 'close'
- ) {
- await manager.ensurePage();
- }
-
- // Handle explicit launch with auto-load state
- if (
- parseResult.command.action === 'launch' &&
- manager instanceof BrowserManager &&
- !parseResult.command.autoStateFilePath
- ) {
- const autoStatePath = getSessionAutoStatePath();
- if (autoStatePath) {
- parseResult.command.autoStateFilePath = autoStatePath;
- }
- }
-
- // Handle close command specially - shuts down daemon
- if (parseResult.command.action === 'close') {
- // Auto-save state before closing
- if (manager instanceof BrowserManager && manager.isLaunched()) {
- const savePath = getSessionSaveStatePath();
- if (savePath) {
- try {
- const { encrypted } = await saveStateToFile(manager, savePath);
- fs.chmodSync(savePath, 0o600);
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(
- `Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
- );
- }
- } catch (err) {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`Failed to auto-save session state:`, err);
- }
- }
- }
- }
-
- const response =
- isIOS && manager instanceof IOSManager
- ? await executeIOSCommand(parseResult.command, manager)
- : await executeCommand(parseResult.command, manager as BrowserManager);
- await safeWrite(socket, serializeResponse(response) + '\n');
-
- if (!shuttingDown) {
- shuttingDown = true;
- setTimeout(() => {
- server.close();
- cleanupSocket();
- process.exit(0);
- }, 100);
- }
-
- commandQueue.length = 0;
- processing = false;
- return;
- }
-
- // Execute command with appropriate handler
- const response =
- isIOS && manager instanceof IOSManager
- ? await executeIOSCommand(parseResult.command, manager)
- : await executeCommand(parseResult.command, manager as BrowserManager);
-
- // Add any launch warnings to the response
- if (manager instanceof BrowserManager) {
- const warnings = manager.getAndClearWarnings();
- if (warnings.length > 0 && response.success && response.data) {
- (response.data as Record).warnings = warnings;
- }
- }
-
- await safeWrite(socket, serializeResponse(response) + '\n');
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- await safeWrite(socket, serializeResponse(errorResponse('error', message)) + '\n').catch(
- () => {}
- ); // Socket may already be destroyed
- }
- }
-
- processing = false;
- }
-
- socket.on('data', (data) => {
- buffer += data.toString();
-
- // Security: Detect and reject HTTP requests to prevent cross-origin attacks.
- // Browsers using fetch() must send HTTP headers (e.g., "POST / HTTP/1.1"),
- // while legitimate clients send raw JSON starting with "{".
- if (!httpChecked) {
- httpChecked = true;
- const trimmed = buffer.trimStart();
- if (/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i.test(trimmed)) {
- socket.destroy();
- return;
- }
- }
-
- // Extract complete lines and enqueue them for serial processing
- while (buffer.includes('\n')) {
- const newlineIdx = buffer.indexOf('\n');
- const line = buffer.substring(0, newlineIdx);
- buffer = buffer.substring(newlineIdx + 1);
-
- if (!line.trim()) continue;
- commandQueue.push(line);
- }
-
- processQueue().catch((err) => {
- // Socket write failures during queue processing are non-fatal;
- // the client has likely disconnected.
- // Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects.
- console.warn('[warn] processQueue error:', err?.message ?? String(err));
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(
- '[DEBUG] processQueue error stack:',
- err?.stack ?? err?.message ?? String(err)
- );
- }
- });
- });
-
- socket.on('error', () => {
- // Client disconnected, ignore
- });
- });
-
- const pidFile = getPidFile();
-
- // Write PID file before listening
- fs.writeFileSync(pidFile, process.pid.toString());
-
- if (isWindows) {
- // Windows: use TCP socket on localhost
- const port = getPortForSession(currentSession);
- const portFile = getPortFile();
- fs.writeFileSync(portFile, port.toString());
- server.listen(port, '127.0.0.1', () => {
- // Daemon is ready on TCP port
- });
- } else {
- // Unix: use Unix domain socket
- const socketPath = getSocketPath();
- server.listen(socketPath, () => {
- // Daemon is ready
- });
- }
-
- server.on('error', (err) => {
- console.error('Server error:', err);
- cleanupSocket();
- process.exit(1);
- });
-
- // Handle shutdown signals
- const shutdown = async () => {
- if (shuttingDown) return;
- shuttingDown = true;
-
- // Clear idle timer
- if (idleTimer) {
- clearTimeout(idleTimer);
- idleTimer = null;
- }
-
- // Auto-save session state before closing (same as the explicit `close` command path)
- if (manager instanceof BrowserManager && manager.isLaunched()) {
- const savePath = getSessionSaveStatePath();
- if (savePath) {
- try {
- const { encrypted } = await saveStateToFile(manager, savePath);
- fs.chmodSync(savePath, 0o600);
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(
- `Auto-saved session state before shutdown: ${savePath}${encrypted ? ' (encrypted)' : ''}`
- );
- }
- } catch (err) {
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
- console.error(`Failed to auto-save session state before shutdown:`, err);
- }
- }
- }
- }
-
- // Stop stream server if running
- if (streamServer) {
- await streamServer.stop();
- streamServer = null;
- // Clean up stream port file
- const streamPortFile = getStreamPortFile();
- try {
- if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
- } catch {
- // Ignore cleanup errors
- }
- }
-
- await manager.close();
- server.close();
- cleanupSocket();
- process.exit(0);
- };
-
- // Wire up idle timeout to shutdown
- shutdownRef = shutdown;
-
- process.on('SIGINT', shutdown);
- process.on('SIGTERM', shutdown);
- process.on('SIGHUP', shutdown);
-
- // Handle unexpected errors - always cleanup
- process.on('uncaughtException', (err) => {
- console.error('Uncaught exception:', err);
- cleanupSocket();
- process.exit(1);
- });
-
- process.on('unhandledRejection', (reason) => {
- console.error('Unhandled rejection:', reason);
- cleanupSocket();
- process.exit(1);
- });
-
- // Cleanup on normal exit
- process.on('exit', () => {
- cleanupSocket();
- });
-
- // Keep process alive
- process.stdin.resume();
-}
-
-// Run daemon if this is the entry point
-if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
- startDaemon().catch((err) => {
- console.error('Daemon error:', err);
- cleanupSocket();
- process.exit(1);
- });
-}
diff --git a/src/diff.test.ts b/src/diff.test.ts
deleted file mode 100644
index 1a96d70..0000000
--- a/src/diff.test.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { diffSnapshots, diffScreenshots } from './diff.js';
-import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core';
-import fs from 'node:fs';
-import path from 'node:path';
-import os from 'node:os';
-
-describe('diffSnapshots', () => {
- it('should report no changes for identical inputs', () => {
- const text = 'heading "Hello"\nbutton "Submit" [ref=e1]';
- const result = diffSnapshots(text, text);
- expect(result.changed).toBe(false);
- expect(result.additions).toBe(0);
- expect(result.removals).toBe(0);
- expect(result.unchanged).toBe(2);
- });
-
- it('should report no changes for empty inputs', () => {
- const result = diffSnapshots('', '');
- expect(result.changed).toBe(false);
- expect(result.additions).toBe(0);
- expect(result.removals).toBe(0);
- expect(result.unchanged).toBe(1);
- });
-
- it('should detect a single-line addition', () => {
- const before = 'heading "Hello"';
- const after = 'heading "Hello"\nbutton "New"';
- const result = diffSnapshots(before, after);
- expect(result.changed).toBe(true);
- expect(result.additions).toBe(1);
- expect(result.removals).toBe(0);
- expect(result.unchanged).toBe(1);
- expect(result.diff).toContain('+ button "New"');
- });
-
- it('should detect a single-line removal', () => {
- const before = 'heading "Hello"\nbutton "Gone"';
- const after = 'heading "Hello"';
- const result = diffSnapshots(before, after);
- expect(result.changed).toBe(true);
- expect(result.additions).toBe(0);
- expect(result.removals).toBe(1);
- expect(result.unchanged).toBe(1);
- expect(result.diff).toContain('- button "Gone"');
- });
-
- it('should detect completely different inputs', () => {
- const before = 'line A\nline B';
- const after = 'line C\nline D';
- const result = diffSnapshots(before, after);
- expect(result.changed).toBe(true);
- expect(result.additions).toBe(2);
- expect(result.removals).toBe(2);
- expect(result.unchanged).toBe(0);
- });
-
- it('should handle mixed additions, removals, and unchanged lines', () => {
- const before = [
- 'heading "Title"',
- 'button "Submit" [ref=e2]',
- 'text "old value"',
- 'footer "Copyright"',
- ].join('\n');
- const after = [
- 'heading "Title"',
- 'button "Submit" [ref=e2] [disabled]',
- 'text "new value"',
- 'link "Help" [ref=e5]',
- 'footer "Copyright"',
- ].join('\n');
- const result = diffSnapshots(before, after);
- expect(result.changed).toBe(true);
- expect(result.additions).toBeGreaterThan(0);
- expect(result.removals).toBeGreaterThan(0);
- expect(result.unchanged).toBeGreaterThan(0);
- expect(result.diff).toContain('+ ');
- expect(result.diff).toContain('- ');
- });
-
- it('should use + prefix for insertions and - prefix for deletions', () => {
- const before = 'alpha';
- const after = 'beta';
- const result = diffSnapshots(before, after);
- const lines = result.diff.split('\n');
- const deletions = lines.filter((l) => l.startsWith('- '));
- const insertions = lines.filter((l) => l.startsWith('+ '));
- expect(deletions.length).toBe(1);
- expect(insertions.length).toBe(1);
- expect(deletions[0]).toBe('- alpha');
- expect(insertions[0]).toBe('+ beta');
- });
-
- it('should use two-space prefix for unchanged lines', () => {
- const text = 'unchanged line';
- const result = diffSnapshots(text, text);
- expect(result.diff).toBe(' unchanged line');
- });
-
- it('should handle multiline to empty', () => {
- const before = 'line 1\nline 2\nline 3';
- const after = '';
- const result = diffSnapshots(before, after);
- expect(result.changed).toBe(true);
- expect(result.removals).toBeGreaterThanOrEqual(3);
- });
-
- it('should handle empty to multiline', () => {
- const before = '';
- const after = 'line 1\nline 2\nline 3';
- const result = diffSnapshots(before, after);
- expect(result.changed).toBe(true);
- expect(result.additions).toBeGreaterThanOrEqual(3);
- });
-});
-
-const canLaunchBrowser = await (async () => {
- try {
- const b = await chromium.launch({ headless: true });
- await b.close();
- return true;
- } catch {
- return false;
- }
-})();
-
-describe.skipIf(!canLaunchBrowser)('diffScreenshots', () => {
- let browser: Browser;
- let context: BrowserContext;
- let page: Page;
-
- beforeAll(async () => {
- browser = await chromium.launch({ headless: true });
- context = await browser.newContext({ viewport: { width: 200, height: 200 } });
- page = await context.newPage();
- });
-
- afterAll(async () => {
- await browser.close();
- });
-
- async function screenshotOfColor(color: string): Promise {
- await page.setContent(`
`);
- return await page.screenshot({ type: 'png' });
- }
-
- it('should report match for identical images', async () => {
- const img = await screenshotOfColor('red');
- const result = await diffScreenshots(context, img, img, {});
- expect(result.match).toBe(true);
- expect(result.differentPixels).toBe(0);
- expect(result.mismatchPercentage).toBe(0);
- expect(result.dimensionMismatch).toBeUndefined();
- if (result.diffPath) fs.unlinkSync(result.diffPath);
- });
-
- it('should detect differences between distinct images', async () => {
- const imgA = await screenshotOfColor('red');
- const imgB = await screenshotOfColor('blue');
- const result = await diffScreenshots(context, imgA, imgB, {});
- expect(result.match).toBe(false);
- expect(result.differentPixels).toBeGreaterThan(0);
- expect(result.mismatchPercentage).toBeGreaterThan(0);
- if (result.diffPath) fs.unlinkSync(result.diffPath);
- });
-
- it('should detect dimension mismatch', async () => {
- const imgA = await screenshotOfColor('white');
- await page.setViewportSize({ width: 100, height: 100 });
- const imgB = await screenshotOfColor('white');
- await page.setViewportSize({ width: 200, height: 200 });
- const result = await diffScreenshots(context, imgA, imgB, {});
- expect(result.dimensionMismatch).toBe(true);
- expect(result.mismatchPercentage).toBe(100);
- if (result.diffPath) fs.unlinkSync(result.diffPath);
- });
-
- it('should write diff image to custom outputPath', async () => {
- const imgA = await screenshotOfColor('green');
- const imgB = await screenshotOfColor('yellow');
- const outputPath = path.join(os.tmpdir(), `diff-test-${Date.now()}.png`);
- const result = await diffScreenshots(context, imgA, imgB, { outputPath });
- expect(result.diffPath).toBe(outputPath);
- expect(fs.existsSync(outputPath)).toBe(true);
- const stat = fs.statSync(outputPath);
- expect(stat.size).toBeGreaterThan(0);
- fs.unlinkSync(outputPath);
- });
-});
diff --git a/src/diff.ts b/src/diff.ts
deleted file mode 100644
index af03cf5..0000000
--- a/src/diff.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-import type { BrowserContext } from 'playwright-core';
-import type { DiffSnapshotData, DiffScreenshotData } from './types.js';
-import { writeFile, mkdir } from 'node:fs/promises';
-import path from 'node:path';
-
-// --- Text diffing (Myers algorithm, line-level) ---
-
-interface DiffEdit {
- type: 'equal' | 'insert' | 'delete';
- line: string;
-}
-
-/**
- * Myers diff algorithm operating on arrays of lines.
- * Returns a minimal edit script.
- */
-function myersDiff(a: string[], b: string[]): DiffEdit[] {
- const n = a.length;
- const m = b.length;
- const max = n + m;
-
- if (max === 0) return [];
-
- // Optimize: if both are identical, skip diff
- if (n === m) {
- let identical = true;
- for (let i = 0; i < n; i++) {
- if (a[i] !== b[i]) {
- identical = false;
- break;
- }
- }
- if (identical) return a.map((line) => ({ type: 'equal' as const, line }));
- }
-
- const vSize = 2 * max + 1;
- const v = new Int32Array(vSize);
- v.fill(-1);
- const trace: Int32Array[] = [];
-
- v[max + 1] = 0;
- for (let d = 0; d <= max; d++) {
- const snapshot = new Int32Array(v);
- trace.push(snapshot);
-
- for (let k = -d; k <= d; k += 2) {
- const idx = k + max;
- let x: number;
- if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
- x = v[idx + 1];
- } else {
- x = v[idx - 1] + 1;
- }
- let y = x - k;
-
- while (x < n && y < m && a[x] === b[y]) {
- x++;
- y++;
- }
-
- v[idx] = x;
-
- if (x >= n && y >= m) {
- return buildEditScript(trace, a, b, max);
- }
- }
- }
-
- return buildEditScript(trace, a, b, max);
-}
-
-function buildEditScript(trace: Int32Array[], a: string[], b: string[], max: number): DiffEdit[] {
- const edits: DiffEdit[] = [];
- let x = a.length;
- let y = b.length;
-
- for (let d = trace.length - 1; d > 0; d--) {
- const v = trace[d];
- const k = x - y;
- const idx = k + max;
-
- let prevK: number;
- if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
- prevK = k + 1;
- } else {
- prevK = k - 1;
- }
-
- const prevIdx = prevK + max;
- let prevX = v[prevIdx];
- let prevY = prevX - prevK;
-
- // Diagonal (equal lines)
- while (x > prevX && y > prevY) {
- x--;
- y--;
- edits.push({ type: 'equal', line: a[x] });
- }
-
- if (x === prevX) {
- y--;
- edits.push({ type: 'insert', line: b[y] });
- } else {
- x--;
- edits.push({ type: 'delete', line: a[x] });
- }
- }
-
- // Remaining diagonal at d=0
- while (x > 0 && y > 0) {
- x--;
- y--;
- edits.push({ type: 'equal', line: a[x] });
- }
-
- edits.reverse();
- return edits;
-}
-
-/**
- * Produce a unified diff string and stats from two snapshot texts.
- */
-export function diffSnapshots(before: string, after: string): DiffSnapshotData {
- const linesA = before.split('\n');
- const linesB = after.split('\n');
-
- const edits = myersDiff(linesA, linesB);
-
- let additions = 0;
- let removals = 0;
- let unchanged = 0;
- const diffLines: string[] = [];
-
- for (const edit of edits) {
- switch (edit.type) {
- case 'equal':
- unchanged++;
- diffLines.push(` ${edit.line}`);
- break;
- case 'insert':
- additions++;
- diffLines.push(`+ ${edit.line}`);
- break;
- case 'delete':
- removals++;
- diffLines.push(`- ${edit.line}`);
- break;
- }
- }
-
- return {
- diff: diffLines.join('\n'),
- additions,
- removals,
- unchanged,
- changed: additions > 0 || removals > 0,
- };
-}
-
-// --- Image diffing (via browser Canvas API) ---
-
-interface PixelDiffResult {
- totalPixels: number;
- differentPixels: number;
- mismatchPercentage: number;
- diffBase64: string;
- dimensionMismatch: boolean;
-}
-
-const DIFF_ROUTE_PREFIX = 'https://agent-browser-diff.localhost';
-
-/**
- * Compare two image buffers using the browser's Canvas API for pixel comparison.
- * Uses an isolated blank page to avoid CSP interference or DOM side effects on the
- * user's page. Images are served via intercepted routes to avoid large base64 payloads
- * through page.evaluate (which can be slow or hit CDP message size limits).
- */
-export async function diffScreenshots(
- context: BrowserContext,
- baselineBuffer: Buffer,
- currentBuffer: Buffer,
- opts: { threshold?: number; outputPath?: string; baselineMime?: string }
-): Promise {
- const baselineMime = opts.baselineMime ?? 'image/png';
- const threshold = opts.threshold ?? 0.1;
-
- const nonce = Math.random().toString(36).slice(2, 10);
- const blankUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/index.html`;
- const baselineUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/baseline.png`;
- const currentUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/current.png`;
-
- const diffPage = await context.newPage();
-
- let blankRouted = false;
- let baselineRouted = false;
- let currentRouted = false;
- try {
- await diffPage.route(blankUrl, (route) =>
- route.fulfill({ body: '', contentType: 'text/html' })
- );
- blankRouted = true;
- await diffPage.route(baselineUrl, (route) =>
- route.fulfill({ body: baselineBuffer, contentType: baselineMime })
- );
- baselineRouted = true;
- await diffPage.route(currentUrl, (route) =>
- route.fulfill({ body: currentBuffer, contentType: 'image/png' })
- );
- currentRouted = true;
-
- await diffPage.goto(blankUrl);
-
- const pixelDiffFn = async (args: {
- baselineUrl: string;
- currentUrl: string;
- threshold: number;
- }) => {
- const g = globalThis as any;
- const doc = g.document;
- const Img = g.Image as new () => any;
- function loadImage(url: string) {
- return new Promise((resolve, reject) => {
- const img = new Img();
- img.onload = () => resolve(img);
- img.onerror = () => reject(new Error('Failed to load image'));
- img.src = url;
- });
- }
- const [imgA, imgB] = (await Promise.all([
- loadImage(args.baselineUrl),
- loadImage(args.currentUrl),
- ])) as any[];
- if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
- const c = doc.createElement('canvas');
- c.width = 1;
- c.height = 1;
- return {
- totalPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
- differentPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
- mismatchPercentage: 100,
- diffBase64: c.toDataURL('image/png').split(',')[1],
- dimensionMismatch: true,
- };
- }
- const w = imgA.width;
- const h = imgA.height;
- const canvasA = doc.createElement('canvas');
- canvasA.width = w;
- canvasA.height = h;
- const ctxA = canvasA.getContext('2d')!;
- ctxA.drawImage(imgA, 0, 0);
- const dataA = ctxA.getImageData(0, 0, w, h).data;
- const canvasB = doc.createElement('canvas');
- canvasB.width = w;
- canvasB.height = h;
- const ctxB = canvasB.getContext('2d')!;
- ctxB.drawImage(imgB, 0, 0);
- const dataB = ctxB.getImageData(0, 0, w, h).data;
- const diffCanvas = doc.createElement('canvas');
- diffCanvas.width = w;
- diffCanvas.height = h;
- const ctxDiff = diffCanvas.getContext('2d')!;
- const diffImageData = ctxDiff.createImageData(w, h);
- const diffData = diffImageData.data;
- const maxColorDistance = args.threshold * 255 * Math.sqrt(3);
- let differentPixels = 0;
- const totalPixels = w * h;
- for (let i = 0; i < totalPixels; i++) {
- const offset = i * 4;
- const rA = dataA[offset],
- gA = dataA[offset + 1],
- bA = dataA[offset + 2];
- const rB = dataB[offset],
- gB = dataB[offset + 1],
- bB = dataB[offset + 2];
- const dr = rA - rB,
- dg = gA - gB,
- db = bA - bB;
- const dist = Math.sqrt(dr * dr + dg * dg + db * db);
- if (dist > maxColorDistance) {
- differentPixels++;
- diffData[offset] = 255;
- diffData[offset + 1] = 0;
- diffData[offset + 2] = 0;
- diffData[offset + 3] = 255;
- } else {
- diffData[offset] = Math.round(rA * 0.3);
- diffData[offset + 1] = Math.round(gA * 0.3);
- diffData[offset + 2] = Math.round(bA * 0.3);
- diffData[offset + 3] = 255;
- }
- }
- ctxDiff.putImageData(diffImageData, 0, 0);
- const diffBase64 = diffCanvas.toDataURL('image/png').split(',')[1];
- return {
- totalPixels,
- differentPixels,
- mismatchPercentage: Math.round((differentPixels / totalPixels) * 10000) / 100,
- diffBase64,
- dimensionMismatch: false,
- };
- };
-
- const result = (await diffPage.evaluate(pixelDiffFn, {
- baselineUrl,
- currentUrl,
- threshold,
- })) as PixelDiffResult;
-
- let outputPath = opts.outputPath;
- if (!outputPath) {
- const tmpDir = path.join(
- process.env.HOME || process.env.USERPROFILE || '/tmp',
- '.agent-browser',
- 'tmp',
- 'diffs'
- );
- await mkdir(tmpDir, { recursive: true });
- outputPath = path.join(tmpDir, `diff-${Date.now()}.png`);
- }
-
- const diffBuffer = Buffer.from(result.diffBase64, 'base64');
- await writeFile(outputPath, diffBuffer);
-
- return {
- diffPath: outputPath,
- totalPixels: result.totalPixels,
- differentPixels: result.differentPixels,
- mismatchPercentage: result.mismatchPercentage,
- match: result.differentPixels === 0,
- ...(result.dimensionMismatch ? { dimensionMismatch: true } : {}),
- };
- } finally {
- if (blankRouted) await diffPage.unroute(blankUrl).catch(() => {});
- if (baselineRouted) await diffPage.unroute(baselineUrl).catch(() => {});
- if (currentRouted) await diffPage.unroute(currentUrl).catch(() => {});
- await diffPage.close().catch(() => {});
- }
-}
diff --git a/src/domain-filter.test.ts b/src/domain-filter.test.ts
deleted file mode 100644
index 36d327f..0000000
--- a/src/domain-filter.test.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js';
-
-describe('domain-filter', () => {
- describe('isDomainAllowed', () => {
- it('should match exact domains', () => {
- expect(isDomainAllowed('example.com', ['example.com'])).toBe(true);
- expect(isDomainAllowed('github.com', ['github.com'])).toBe(true);
- });
-
- it('should reject non-matching domains', () => {
- expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false);
- expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false);
- });
-
- it('should match wildcard patterns', () => {
- expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true);
- expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true);
- });
-
- it('should match bare domain against wildcard pattern', () => {
- expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true);
- });
-
- it('should reject non-matching wildcard patterns', () => {
- expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false);
- expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false);
- });
-
- it('should return false for empty allowlist', () => {
- expect(isDomainAllowed('example.com', [])).toBe(false);
- });
-
- it('should match against multiple patterns', () => {
- const patterns = ['example.com', '*.github.com', 'vercel.app'];
- expect(isDomainAllowed('example.com', patterns)).toBe(true);
- expect(isDomainAllowed('api.github.com', patterns)).toBe(true);
- expect(isDomainAllowed('vercel.app', patterns)).toBe(true);
- expect(isDomainAllowed('evil.com', patterns)).toBe(false);
- });
-
- it('should not partially match domain suffixes without wildcard', () => {
- expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false);
- });
- });
-
- describe('parseDomainList', () => {
- it('should split comma-separated domains', () => {
- expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']);
- });
-
- it('should trim whitespace', () => {
- expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']);
- });
-
- it('should lowercase domains', () => {
- expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']);
- });
-
- it('should filter empty entries', () => {
- expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']);
- });
-
- it('should handle empty string', () => {
- expect(parseDomainList('')).toEqual([]);
- });
-
- it('should preserve wildcard prefixes', () => {
- expect(parseDomainList('*.example.com')).toEqual(['*.example.com']);
- });
- });
-
- describe('buildWebSocketFilterScript', () => {
- it('should produce a valid JavaScript IIFE', () => {
- const script = buildWebSocketFilterScript(['example.com', '*.github.com']);
- expect(script).toContain('_allowedDomains');
- expect(script).toContain('"example.com"');
- expect(script).toContain('"*.github.com"');
- });
-
- it('should embed the domain list as JSON', () => {
- const script = buildWebSocketFilterScript(['a.com']);
- expect(script).toContain('["a.com"]');
- });
-
- it('should include WebSocket, EventSource, and sendBeacon patches', () => {
- const script = buildWebSocketFilterScript(['a.com']);
- expect(script).toContain('WebSocket');
- expect(script).toContain('EventSource');
- expect(script).toContain('SecurityError');
- expect(script).toContain('sendBeacon');
- });
-
- it('should handle empty allowlist', () => {
- const script = buildWebSocketFilterScript([]);
- expect(script).toContain('[]');
- });
-
- it('should include domain matching logic consistent with isDomainAllowed', () => {
- const script = buildWebSocketFilterScript(['*.example.com']);
- expect(script).toContain('_isDomainAllowed');
- expect(script).toContain('slice(1)');
- expect(script).toContain('slice(2)');
- });
- });
-});
diff --git a/src/domain-filter.ts b/src/domain-filter.ts
deleted file mode 100644
index 19dbff8..0000000
--- a/src/domain-filter.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import type { BrowserContext, Route } from 'playwright-core';
-
-/**
- * Checks whether a hostname matches one of the allowed domain patterns.
- * Patterns support exact match ("example.com") and wildcard prefix ("*.example.com").
- */
-export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean {
- for (const pattern of allowedDomains) {
- if (pattern.startsWith('*.')) {
- const suffix = pattern.slice(1); // ".example.com"
- if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) {
- return true;
- }
- } else if (hostname === pattern) {
- return true;
- }
- }
- return false;
-}
-
-export function parseDomainList(raw: string): string[] {
- return raw
- .split(',')
- .map((d) => d.trim().toLowerCase())
- .filter((d) => d.length > 0);
-}
-
-/**
- * Build the init script source that monkey-patches WebSocket, EventSource,
- * and navigator.sendBeacon to block connections to non-allowed domains.
- * Exported for testing.
- */
-export function buildWebSocketFilterScript(allowedDomains: string[]): string {
- const serialized = JSON.stringify(allowedDomains);
- return `(function() {
- var _allowedDomains = ${serialized};
- function _isDomainAllowed(hostname) {
- hostname = hostname.toLowerCase();
- for (var i = 0; i < _allowedDomains.length; i++) {
- var pattern = _allowedDomains[i];
- if (pattern.indexOf('*.') === 0) {
- var suffix = pattern.slice(1);
- if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) {
- return true;
- }
- } else if (hostname === pattern) {
- return true;
- }
- }
- return false;
- }
- function _checkUrl(url) {
- try {
- var parsed = new URL(url);
- return _isDomainAllowed(parsed.hostname);
- } catch(e) {
- return false;
- }
- }
- if (typeof WebSocket !== 'undefined') {
- var _OrigWS = WebSocket;
- WebSocket = function(url, protocols) {
- if (!_checkUrl(url)) {
- throw new DOMException(
- 'WebSocket connection to ' + url + ' blocked by domain allowlist',
- 'SecurityError'
- );
- }
- if (protocols !== undefined) {
- return new _OrigWS(url, protocols);
- }
- return new _OrigWS(url);
- };
- WebSocket.prototype = _OrigWS.prototype;
- WebSocket.CONNECTING = _OrigWS.CONNECTING;
- WebSocket.OPEN = _OrigWS.OPEN;
- WebSocket.CLOSING = _OrigWS.CLOSING;
- WebSocket.CLOSED = _OrigWS.CLOSED;
- }
- if (typeof EventSource !== 'undefined') {
- var _OrigES = EventSource;
- EventSource = function(url, opts) {
- if (!_checkUrl(url)) {
- throw new DOMException(
- 'EventSource connection to ' + url + ' blocked by domain allowlist',
- 'SecurityError'
- );
- }
- return new _OrigES(url, opts);
- };
- EventSource.prototype = _OrigES.prototype;
- EventSource.CONNECTING = _OrigES.CONNECTING;
- EventSource.OPEN = _OrigES.OPEN;
- EventSource.CLOSED = _OrigES.CLOSED;
- }
- if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
- var _origSendBeacon = navigator.sendBeacon.bind(navigator);
- navigator.sendBeacon = function(url, data) {
- if (!_checkUrl(url)) {
- return false;
- }
- return _origSendBeacon(url, data);
- };
- }
-})();`;
-}
-
-/**
- * Installs a context-level route that enforces the domain allowlist.
- * Both document navigations and sub-resource requests (scripts, images, fetch, etc.)
- * to non-allowed domains are blocked, preventing data exfiltration.
- * Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources
- * but blocked for document navigations.
- *
- * Also installs an init script that patches WebSocket, EventSource, and
- * navigator.sendBeacon to block connections to non-allowed domains. This is
- * a best-effort defense: if eval is permitted by action policy, page scripts
- * could theoretically restore the originals. Denying the eval action
- * category closes that loophole.
- */
-export async function installDomainFilter(
- context: BrowserContext,
- allowedDomains: string[]
-): Promise {
- if (allowedDomains.length === 0) return;
-
- await context.addInitScript(buildWebSocketFilterScript(allowedDomains));
-
- await context.route('**/*', async (route: Route) => {
- const request = route.request();
- const urlStr = request.url();
-
- if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) {
- if (request.resourceType() === 'document') {
- await route.abort('blockedbyclient');
- } else {
- await route.continue();
- }
- return;
- }
-
- let hostname: string;
- try {
- hostname = new URL(urlStr).hostname.toLowerCase();
- } catch {
- await route.abort('blockedbyclient');
- return;
- }
-
- if (isDomainAllowed(hostname, allowedDomains)) {
- await route.continue();
- } else {
- await route.abort('blockedbyclient');
- }
- });
-}
diff --git a/src/encryption.test.ts b/src/encryption.test.ts
deleted file mode 100644
index 06fd567..0000000
--- a/src/encryption.test.ts
+++ /dev/null
@@ -1,474 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import * as crypto from 'crypto';
-import {
- encryptData,
- decryptData,
- getEncryptionKey,
- getKeyFilePath,
- isEncryptedPayload,
- ENCRYPTION_KEY_ENV,
- IV_LENGTH,
- type EncryptedPayload,
-} from './encryption.js';
-
-// Mock node:fs to isolate getEncryptionKey from the local filesystem
-const mockFs = vi.hoisted(() => ({
- existsSync: vi.fn(),
- readFileSync: vi.fn(),
- originals: {} as Pick,
-}));
-vi.mock('node:fs', async (importOriginal) => {
- const actual = await importOriginal();
- mockFs.originals = { existsSync: actual.existsSync, readFileSync: actual.readFileSync };
- mockFs.existsSync.mockImplementation(actual.existsSync);
- mockFs.readFileSync.mockImplementation(actual.readFileSync);
- return { ...actual, existsSync: mockFs.existsSync, readFileSync: mockFs.readFileSync };
-});
-
-// Generate a valid test key (256 bits = 32 bytes = 64 hex chars)
-const generateTestKey = () => crypto.randomBytes(32);
-const generateTestKeyHex = () => crypto.randomBytes(32).toString('hex');
-
-describe('encryption', () => {
- describe('encryptData / decryptData', () => {
- it('should round-trip encrypt and decrypt data correctly', () => {
- const key = generateTestKey();
- const plaintext = 'Hello, World! This is a test message.';
-
- const encrypted = encryptData(plaintext, key);
- const decrypted = decryptData(encrypted, key);
-
- expect(decrypted).toBe(plaintext);
- });
-
- it('should round-trip with complex JSON data', () => {
- const key = generateTestKey();
- const data = {
- cookies: [{ name: 'session', value: 'abc123', domain: '.example.com' }],
- localStorage: { theme: 'dark', userId: '12345' },
- sessionStorage: {},
- };
- const plaintext = JSON.stringify(data);
-
- const encrypted = encryptData(plaintext, key);
- const decrypted = decryptData(encrypted, key);
-
- expect(JSON.parse(decrypted)).toEqual(data);
- });
-
- it('should round-trip with empty string', () => {
- const key = generateTestKey();
- const plaintext = '';
-
- const encrypted = encryptData(plaintext, key);
- const decrypted = decryptData(encrypted, key);
-
- expect(decrypted).toBe(plaintext);
- });
-
- it('should round-trip with unicode characters', () => {
- const key = generateTestKey();
- const plaintext = '你好世界 🌍 Привет мир émojis: 🔐🔑';
-
- const encrypted = encryptData(plaintext, key);
- const decrypted = decryptData(encrypted, key);
-
- expect(decrypted).toBe(plaintext);
- });
-
- it('should round-trip with large data', () => {
- const key = generateTestKey();
- const plaintext = 'x'.repeat(100000); // 100KB of data
-
- const encrypted = encryptData(plaintext, key);
- const decrypted = decryptData(encrypted, key);
-
- expect(decrypted).toBe(plaintext);
- });
- });
-
- describe('IV uniqueness', () => {
- it('should generate different IVs for each encryption', () => {
- const key = generateTestKey();
- const plaintext = 'Same message encrypted twice';
-
- const encrypted1 = encryptData(plaintext, key);
- const encrypted2 = encryptData(plaintext, key);
-
- // IVs should be different
- expect(encrypted1.iv).not.toBe(encrypted2.iv);
-
- // Ciphertext should also be different due to different IVs
- expect(encrypted1.data).not.toBe(encrypted2.data);
-
- // Both should decrypt to the same plaintext
- expect(decryptData(encrypted1, key)).toBe(plaintext);
- expect(decryptData(encrypted2, key)).toBe(plaintext);
- });
-
- it('should have correct IV length', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- const ivBuffer = Buffer.from(encrypted.iv, 'base64');
- expect(ivBuffer.length).toBe(IV_LENGTH);
- });
- });
-
- describe('authentication (tamper detection)', () => {
- it('should throw error when auth tag is tampered', () => {
- const key = generateTestKey();
- const plaintext = 'Sensitive data';
-
- const encrypted = encryptData(plaintext, key);
-
- // Tamper with the auth tag
- const tamperedAuthTag = Buffer.from(encrypted.authTag, 'base64');
- tamperedAuthTag[0] ^= 0xff; // Flip bits
- const tamperedPayload: EncryptedPayload = {
- ...encrypted,
- authTag: tamperedAuthTag.toString('base64'),
- };
-
- expect(() => decryptData(tamperedPayload, key)).toThrow();
- });
-
- it('should throw error when ciphertext is tampered', () => {
- const key = generateTestKey();
- const plaintext = 'Sensitive data';
-
- const encrypted = encryptData(plaintext, key);
-
- // Tamper with the ciphertext
- const tamperedData = Buffer.from(encrypted.data, 'base64');
- tamperedData[0] ^= 0xff; // Flip bits
- const tamperedPayload: EncryptedPayload = {
- ...encrypted,
- data: tamperedData.toString('base64'),
- };
-
- expect(() => decryptData(tamperedPayload, key)).toThrow();
- });
-
- it('should throw error when IV is tampered', () => {
- const key = generateTestKey();
- const plaintext = 'Sensitive data';
-
- const encrypted = encryptData(plaintext, key);
-
- // Tamper with the IV
- const tamperedIv = Buffer.from(encrypted.iv, 'base64');
- tamperedIv[0] ^= 0xff; // Flip bits
- const tamperedPayload: EncryptedPayload = {
- ...encrypted,
- iv: tamperedIv.toString('base64'),
- };
-
- expect(() => decryptData(tamperedPayload, key)).toThrow();
- });
- });
-
- describe('wrong key handling', () => {
- it('should throw error when decrypting with wrong key', () => {
- const key1 = generateTestKey();
- const key2 = generateTestKey();
- const plaintext = 'Sensitive data';
-
- const encrypted = encryptData(plaintext, key1);
-
- // Try to decrypt with a different key
- expect(() => decryptData(encrypted, key2)).toThrow();
- });
-
- it('should throw error when key is partially wrong', () => {
- const key = generateTestKey();
- const plaintext = 'Sensitive data';
-
- const encrypted = encryptData(plaintext, key);
-
- // Create a key with one byte different
- const wrongKey = Buffer.from(key);
- wrongKey[0] ^= 0xff;
-
- expect(() => decryptData(encrypted, wrongKey)).toThrow();
- });
- });
-
- describe('malformed payload detection', () => {
- it('should throw error for empty IV', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- const malformed: EncryptedPayload = {
- ...encrypted,
- iv: '',
- };
-
- expect(() => decryptData(malformed, key)).toThrow();
- });
-
- it('should throw error for empty auth tag', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- const malformed: EncryptedPayload = {
- ...encrypted,
- authTag: '',
- };
-
- expect(() => decryptData(malformed, key)).toThrow();
- });
-
- it('should throw error for invalid base64 in IV', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- const malformed: EncryptedPayload = {
- ...encrypted,
- iv: '!!!not-valid-base64!!!',
- };
-
- expect(() => decryptData(malformed, key)).toThrow();
- });
-
- it('should throw error for truncated auth tag', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- // Truncate auth tag to just 4 bytes (minimum allowed, but wrong value)
- // This won't match the actual tag, so authentication will fail
- const truncatedTag = crypto.randomBytes(4); // Random 4 bytes won't match
- const malformed: EncryptedPayload = {
- ...encrypted,
- authTag: truncatedTag.toString('base64'),
- };
-
- // Note: With Node.js deprecation warning, very short tags may still be
- // accepted but will fail authentication during decipher.final()
- expect(() => decryptData(malformed, key)).toThrow();
- });
-
- it('should throw error for completely wrong auth tag length', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- // Use a completely wrong auth tag (right length but wrong value)
- const wrongTag = crypto.randomBytes(16); // Same length as real tag
- const malformed: EncryptedPayload = {
- ...encrypted,
- authTag: wrongTag.toString('base64'),
- };
-
- expect(() => decryptData(malformed, key)).toThrow();
- });
- });
-
- describe('getEncryptionKey', () => {
- const originalEnv = process.env[ENCRYPTION_KEY_ENV];
- const keyFilePath = getKeyFilePath();
-
- function mockKeyFile(content?: string): void {
- const exists = content !== undefined;
- mockFs.existsSync.mockImplementation((path: string) => {
- if (path === keyFilePath) return exists;
- return mockFs.originals.existsSync(path);
- });
- if (exists) {
- mockFs.readFileSync.mockImplementation((path: string, encoding?: string) => {
- if (path === keyFilePath) return content;
- return mockFs.originals.readFileSync(path, encoding as BufferEncoding);
- });
- }
- }
-
- afterEach(() => {
- mockFs.existsSync.mockImplementation(mockFs.originals.existsSync);
- mockFs.readFileSync.mockImplementation(mockFs.originals.readFileSync);
- if (originalEnv !== undefined) {
- process.env[ENCRYPTION_KEY_ENV] = originalEnv;
- } else {
- delete process.env[ENCRYPTION_KEY_ENV];
- }
- });
-
- describe('from env var', () => {
- beforeEach(() => {
- mockKeyFile();
- });
-
- it('should return null when env var is not set', () => {
- delete process.env[ENCRYPTION_KEY_ENV];
- expect(getEncryptionKey()).toBeNull();
- });
-
- it('should return null for empty string', () => {
- process.env[ENCRYPTION_KEY_ENV] = '';
- expect(getEncryptionKey()).toBeNull();
- });
-
- it('should return null for invalid hex (too short)', () => {
- process.env[ENCRYPTION_KEY_ENV] = 'abc123'; // Only 6 chars, need 64
- expect(getEncryptionKey()).toBeNull();
- });
-
- it('should return null for invalid hex (too long)', () => {
- process.env[ENCRYPTION_KEY_ENV] = 'a'.repeat(128); // 128 chars, need 64
- expect(getEncryptionKey()).toBeNull();
- });
-
- it('should return null for non-hex characters', () => {
- process.env[ENCRYPTION_KEY_ENV] = 'g'.repeat(64); // 'g' is not hex
- expect(getEncryptionKey()).toBeNull();
- });
-
- it('should return valid key buffer for correct hex string', () => {
- const keyHex = generateTestKeyHex();
- process.env[ENCRYPTION_KEY_ENV] = keyHex;
-
- const key = getEncryptionKey();
- expect(key).not.toBeNull();
- expect(key).toBeInstanceOf(Buffer);
- expect(key!.length).toBe(32); // 256 bits
- expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
- });
-
- it('should accept uppercase hex', () => {
- const keyHex = generateTestKeyHex().toUpperCase();
- process.env[ENCRYPTION_KEY_ENV] = keyHex;
-
- const key = getEncryptionKey();
- expect(key).not.toBeNull();
- expect(key!.length).toBe(32);
- });
-
- it('should accept mixed case hex', () => {
- const keyHex = generateTestKeyHex();
- const mixedCase = keyHex
- .split('')
- .map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
- .join('');
- process.env[ENCRYPTION_KEY_ENV] = mixedCase;
-
- const key = getEncryptionKey();
- expect(key).not.toBeNull();
- expect(key!.length).toBe(32);
- });
- });
-
- describe('from key file fallback', () => {
- beforeEach(() => {
- delete process.env[ENCRYPTION_KEY_ENV];
- });
-
- it('should return key when key file exists with valid hex', () => {
- const keyHex = generateTestKeyHex();
- mockKeyFile(keyHex);
-
- const key = getEncryptionKey();
- expect(key).not.toBeNull();
- expect(key).toBeInstanceOf(Buffer);
- expect(key!.length).toBe(32);
- expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
- });
-
- it('should return null when key file does not exist', () => {
- mockKeyFile();
- expect(getEncryptionKey()).toBeNull();
- });
-
- it('should return null when key file contains invalid hex', () => {
- mockKeyFile('not-valid-hex');
- expect(getEncryptionKey()).toBeNull();
- });
- });
- });
-
- describe('isEncryptedPayload', () => {
- it('should return true for valid encrypted payload', () => {
- const key = generateTestKey();
- const encrypted = encryptData('test', key);
-
- expect(isEncryptedPayload(encrypted)).toBe(true);
- });
-
- it('should return false for null', () => {
- expect(isEncryptedPayload(null)).toBe(false);
- });
-
- it('should return false for undefined', () => {
- expect(isEncryptedPayload(undefined)).toBe(false);
- });
-
- it('should return false for plain object without encrypted flag', () => {
- expect(isEncryptedPayload({ data: 'test' })).toBe(false);
- });
-
- it('should return false for object with encrypted: false', () => {
- expect(
- isEncryptedPayload({
- encrypted: false,
- version: 1,
- iv: 'test',
- authTag: 'test',
- data: 'test',
- })
- ).toBe(false);
- });
-
- it('should return false for object missing version', () => {
- expect(
- isEncryptedPayload({
- encrypted: true,
- iv: 'test',
- authTag: 'test',
- data: 'test',
- })
- ).toBe(false);
- });
-
- it('should return false for object missing iv', () => {
- expect(
- isEncryptedPayload({
- encrypted: true,
- version: 1,
- authTag: 'test',
- data: 'test',
- })
- ).toBe(false);
- });
-
- it('should return false for object missing authTag', () => {
- expect(
- isEncryptedPayload({
- encrypted: true,
- version: 1,
- iv: 'test',
- data: 'test',
- })
- ).toBe(false);
- });
-
- it('should return false for object missing data', () => {
- expect(
- isEncryptedPayload({
- encrypted: true,
- version: 1,
- iv: 'test',
- authTag: 'test',
- })
- ).toBe(false);
- });
-
- it('should return false for array', () => {
- expect(isEncryptedPayload([])).toBe(false);
- });
-
- it('should return false for string', () => {
- expect(isEncryptedPayload('encrypted')).toBe(false);
- });
-
- it('should return false for number', () => {
- expect(isEncryptedPayload(42)).toBe(false);
- });
- });
-});
diff --git a/src/encryption.ts b/src/encryption.ts
deleted file mode 100644
index 3b366a0..0000000
--- a/src/encryption.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Encryption utilities for state file protection using AES-256-GCM.
- */
-
-import * as crypto from 'crypto';
-import { execSync } from 'node:child_process';
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { join } from 'node:path';
-import os from 'node:os';
-
-// ============================================
-// Constants
-// ============================================
-export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
-export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
-export const IV_LENGTH = 12; // 96 bits for GCM
-const KEY_FILE_NAME = '.encryption-key';
-
-/**
- * Encrypted payload structure.
- */
-export interface EncryptedPayload {
- version: 1;
- encrypted: true;
- iv: string; // Base64 encoded
- authTag: string; // Base64 encoded
- data: string; // Base64 encoded ciphertext
-}
-
-export function getKeyFilePath(): string {
- return join(os.homedir(), '.agent-browser', KEY_FILE_NAME);
-}
-
-/**
- * Restrict file permissions to the current user only.
- * On Unix, the caller should use `mode: 0o600` when writing. This function
- * handles Windows where Node's mode parameter is ignored.
- */
-export function restrictFilePermissions(filePath: string): void {
- if (os.platform() !== 'win32') return;
- try {
- execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, {
- stdio: 'ignore',
- windowsHide: true,
- });
- } catch {
- // Best-effort; may fail in some environments (containers, restricted shells)
- }
-}
-
-/**
- * Restrict directory permissions to the current user only.
- * On Unix, the caller should use `mode: 0o700` when creating. This function
- * handles Windows where Node's mode parameter is ignored.
- */
-export function restrictDirPermissions(dirPath: string): void {
- if (os.platform() !== 'win32') return;
- try {
- execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, {
- stdio: 'ignore',
- windowsHide: true,
- });
- } catch {
- // Best-effort
- }
-}
-
-function parseKeyHex(keyHex: string): Buffer | null {
- if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null;
- return Buffer.from(keyHex.trim(), 'hex');
-}
-
-/**
- * Get encryption key from environment variable or key file.
- * The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
- * Generate with: openssl rand -hex 32
- *
- * Checks (in order):
- * 1. AGENT_BROWSER_ENCRYPTION_KEY env var
- * 2. ~/.agent-browser/.encryption-key file
- *
- * @returns Buffer containing the key, or null if not available
- */
-export function getEncryptionKey(): Buffer | null {
- const keyHex = process.env[ENCRYPTION_KEY_ENV];
- if (keyHex) {
- const key = parseKeyHex(keyHex);
- if (!key) {
- console.warn(
- `Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
- `Generate one with: openssl rand -hex 32`
- );
- return null;
- }
- return key;
- }
-
- const keyFilePath = getKeyFilePath();
- if (existsSync(keyFilePath)) {
- try {
- const fileHex = readFileSync(keyFilePath, 'utf-8');
- return parseKeyHex(fileHex);
- } catch {
- return null;
- }
- }
-
- return null;
-}
-
-/**
- * Ensure an encryption key is available, auto-generating one if needed.
- * On first call without an existing key, generates a random 256-bit key
- * and writes it to ~/.agent-browser/.encryption-key (mode 0600).
- */
-export function ensureEncryptionKey(): Buffer {
- const existing = getEncryptionKey();
- if (existing) return existing;
-
- const key = crypto.randomBytes(32);
- const keyHex = key.toString('hex');
-
- const dir = join(os.homedir(), '.agent-browser');
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- restrictDirPermissions(dir);
- }
-
- const keyFilePath = getKeyFilePath();
- writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 });
- restrictFilePermissions(keyFilePath);
-
- console.error(
- `[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}`
- );
-
- return key;
-}
-
-/**
- * Encrypt data using AES-256-GCM.
- * Returns a JSON-serializable payload with IV, auth tag, and encrypted data.
- *
- * @param plaintext - The string to encrypt
- * @param key - The 256-bit encryption key
- * @returns Encrypted payload object
- */
-export function encryptData(plaintext: string, key: Buffer): EncryptedPayload {
- const iv = crypto.randomBytes(IV_LENGTH);
- const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
-
- let encrypted = cipher.update(plaintext, 'utf8');
- encrypted = Buffer.concat([encrypted, cipher.final()]);
-
- return {
- version: 1,
- encrypted: true,
- iv: iv.toString('base64'),
- authTag: cipher.getAuthTag().toString('base64'),
- data: encrypted.toString('base64'),
- };
-}
-
-/**
- * Decrypt data using AES-256-GCM.
- *
- * @param payload - The encrypted payload object
- * @param key - The 256-bit encryption key
- * @returns Decrypted plaintext string
- * @throws Error if decryption fails (wrong key, tampered data, etc.)
- */
-export function decryptData(payload: EncryptedPayload, key: Buffer): string {
- const iv = Buffer.from(payload.iv, 'base64');
- const authTag = Buffer.from(payload.authTag, 'base64');
- const encryptedData = Buffer.from(payload.data, 'base64');
-
- const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
- decipher.setAuthTag(authTag);
-
- let decrypted = decipher.update(encryptedData);
- decrypted = Buffer.concat([decrypted, decipher.final()]);
-
- return decrypted.toString('utf8');
-}
-
-/**
- * Check if a parsed JSON object is an encrypted payload.
- *
- * @param data - The object to check
- * @returns True if the object is a valid encrypted payload
- */
-export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
- return (
- typeof data === 'object' &&
- data !== null &&
- 'encrypted' in data &&
- (data as EncryptedPayload).encrypted === true &&
- 'version' in data &&
- 'iv' in data &&
- 'authTag' in data &&
- 'data' in data
- );
-}
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index a710c2d..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-export { BrowserManager, getDefaultTimeout } from './browser.js';
-export type {
- BrowserLaunchOptions,
- NavigateOptions,
- ScreencastFrame,
- ScreencastOptions,
-} from './browser.js';
-export { IOSManager } from './ios-manager.js';
-export { executeCommand } from './actions.js';
-export type { Command, LaunchCommand, NavigateCommand, Response } from './types.js';
-export {
- cleanupSocket,
- getAppDir,
- getConnectionInfo,
- getPidFile,
- getPortFile,
- getPortForSession,
- getSession,
- getSocketDir,
- getSocketPath,
- getStreamPortFile,
- isDaemonRunning,
- safeWrite,
- setSession,
- startDaemon,
-} from './daemon.js';
diff --git a/src/inspect-server.test.ts b/src/inspect-server.test.ts
deleted file mode 100644
index 534861f..0000000
--- a/src/inspect-server.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { injectSessionId, stripSessionId } from './inspect-server.js';
-
-describe('injectSessionId', () => {
- it('should inject sessionId into a command', () => {
- const input = '{"id":1,"method":"DOM.getDocument"}';
- const result = JSON.parse(injectSessionId(input, 'abc123'));
- expect(result.sessionId).toBe('abc123');
- expect(result.method).toBe('DOM.getDocument');
- expect(result.id).toBe(1);
- });
-
- it('should inject sessionId into an empty object', () => {
- const result = JSON.parse(injectSessionId('{}', 'abc'));
- expect(result.sessionId).toBe('abc');
- });
-});
-
-describe('stripSessionId', () => {
- it('should remove sessionId from a message', () => {
- const input = '{"id":1,"result":{},"sessionId":"abc123"}';
- const result = JSON.parse(stripSessionId(input));
- expect(result.sessionId).toBeUndefined();
- expect(result.id).toBe(1);
- });
-});
-
-describe('inject then strip roundtrip', () => {
- it('should return the original message after inject + strip', () => {
- const input = '{"id":42,"method":"Runtime.evaluate"}';
- const injected = injectSessionId(input, 'sess1');
- const stripped = stripSessionId(injected);
- expect(JSON.parse(stripped)).toEqual(JSON.parse(input));
- });
-});
diff --git a/src/inspect-server.ts b/src/inspect-server.ts
deleted file mode 100644
index f4f209c..0000000
--- a/src/inspect-server.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import http from 'node:http';
-import { WebSocketServer, WebSocket } from 'ws';
-
-export interface InspectServerOptions {
- chromeHostPort: string;
- targetId: string;
- chromeWsUrl: string;
-}
-
-let nextAttachId = -1000;
-
-export function injectSessionId(json: string, sessionId: string): string {
- const msg = JSON.parse(json);
- msg.sessionId = sessionId;
- return JSON.stringify(msg);
-}
-
-export function stripSessionId(json: string): string {
- const msg = JSON.parse(json);
- delete msg.sessionId;
- return JSON.stringify(msg);
-}
-
-// The Node.js path opens its own WebSocket to Chrome rather than sharing
-// Playwright's internal connection. This avoids interfering with Playwright's
-// CDP session management. The Rust/native path takes the opposite approach,
-// sharing the daemon's existing browser-level WebSocket via InspectProxyHandle.
-export class InspectServer {
- private httpServer: http.Server;
- private wss: WebSocketServer;
- private chromeWs: WebSocket | null = null;
- private sessions = new Map();
- private pendingAttaches = new Map void>();
- private _port: number = 0;
-
- constructor(private options: InspectServerOptions) {
- this.httpServer = http.createServer(this.handleHttp.bind(this));
- this.wss = new WebSocketServer({ server: this.httpServer, path: '/ws' });
- this.wss.on('connection', this.handleWsConnection.bind(this));
- }
-
- get port(): number {
- return this._port;
- }
-
- async start(): Promise {
- await this.connectChrome();
- return new Promise((resolve, reject) => {
- this.httpServer.listen(0, '127.0.0.1', () => {
- const addr = this.httpServer.address();
- if (addr && typeof addr !== 'string') {
- this._port = addr.port;
- }
- resolve();
- });
- this.httpServer.on('error', reject);
- });
- }
-
- stop(): void {
- for (const [sessionId, devtoolsWs] of this.sessions) {
- this.detachSession(sessionId);
- devtoolsWs.close();
- }
- this.sessions.clear();
- this.chromeWs?.close();
- this.chromeWs = null;
- this.wss.close();
- this.httpServer.close();
- }
-
- private connectChrome(): Promise {
- return new Promise((resolve, reject) => {
- const ws = new WebSocket(this.options.chromeWsUrl);
- ws.on('open', () => {
- this.chromeWs = ws;
- resolve();
- });
- ws.on('error', (err) => {
- if (!this.chromeWs) {
- reject(new Error(`Chrome WebSocket connection failed: ${err.message}`));
- } else {
- console.error('[inspect] Chrome WebSocket error:', err.message);
- for (const devtoolsWs of this.sessions.values()) {
- devtoolsWs.close();
- }
- this.sessions.clear();
- }
- });
- ws.on('close', () => {
- this.chromeWs = null;
- for (const devtoolsWs of this.sessions.values()) {
- devtoolsWs.close();
- }
- this.sessions.clear();
- });
- ws.on('message', (data) => this.handleChromeMessage(data));
- });
- }
-
- private handleChromeMessage(data: unknown): void {
- try {
- const text = String(data);
- const msg = JSON.parse(text);
-
- // Check if this is a response to a pending attachToTarget request
- if (msg.id != null && msg.id < 0) {
- const resolve = this.pendingAttaches.get(msg.id);
- if (resolve) {
- this.pendingAttaches.delete(msg.id);
- resolve(msg.result?.sessionId ?? null);
- return;
- }
- }
-
- // Route session-scoped messages to the correct DevTools client
- const sessionId: string | undefined = msg.sessionId;
- if (!sessionId) return;
-
- const devtoolsWs = this.sessions.get(sessionId);
- if (!devtoolsWs || devtoolsWs.readyState !== WebSocket.OPEN) return;
-
- devtoolsWs.send(stripSessionId(text));
- } catch (err) {
- console.error('[inspect] Chrome message handling error:', err);
- }
- }
-
- private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
- if (req.url === '/' || req.url === '') {
- const location = `http://${this.options.chromeHostPort}/devtools/devtools_app.html?ws=127.0.0.1:${this._port}/ws`;
- res.writeHead(302, { Location: location, 'Content-Type': 'text/html' });
- res.end(`Redirecting to ${location} `);
- return;
- }
- res.writeHead(404);
- res.end();
- }
-
- private handleWsConnection(devtoolsWs: WebSocket): void {
- if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) {
- devtoolsWs.close();
- return;
- }
-
- const attachId = nextAttachId--;
- const attachMsg = JSON.stringify({
- id: attachId,
- method: 'Target.attachToTarget',
- params: { targetId: this.options.targetId, flatten: true },
- });
-
- // Track the session ID once attach completes; closed by close/error handlers
- // that are registered immediately (before the async attach resolves) so
- // early disconnects still trigger cleanup.
- let sessionId: string | null = null;
-
- devtoolsWs.on('close', () => {
- if (sessionId) {
- this.sessions.delete(sessionId);
- this.detachSession(sessionId);
- }
- });
-
- devtoolsWs.on('error', () => {
- if (sessionId) {
- this.sessions.delete(sessionId);
- this.detachSession(sessionId);
- }
- devtoolsWs.close();
- });
-
- const messageBuffer: string[] = [];
-
- devtoolsWs.on('message', (data) => {
- if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
- const text = String(data);
- if (!sessionId) {
- messageBuffer.push(text);
- return;
- }
- try {
- this.chromeWs.send(injectSessionId(text, sessionId));
- } catch (err) {
- console.error('[inspect] DevTools message forwarding error:', err);
- }
- });
-
- const attachPromise = new Promise((resolve) => {
- this.pendingAttaches.set(attachId, resolve);
- this.chromeWs!.send(attachMsg);
- setTimeout(() => {
- if (this.pendingAttaches.has(attachId)) {
- this.pendingAttaches.delete(attachId);
- resolve(null);
- }
- }, 5000);
- });
-
- attachPromise.then((sid) => {
- if (!sid) {
- console.error('[inspect] Failed to attach to target');
- devtoolsWs.close();
- return;
- }
-
- if (devtoolsWs.readyState !== WebSocket.OPEN) {
- this.detachSession(sid);
- return;
- }
-
- sessionId = sid;
- this.sessions.set(sid, devtoolsWs);
-
- for (const buffered of messageBuffer) {
- try {
- this.chromeWs!.send(injectSessionId(buffered, sid));
- } catch (err) {
- console.error('[inspect] DevTools message forwarding error:', err);
- }
- }
- messageBuffer.length = 0;
- });
- }
-
- private detachSession(sessionId: string): void {
- if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
- const detachId = nextAttachId--;
- const detachMsg = JSON.stringify({
- id: detachId,
- method: 'Target.detachFromTarget',
- params: { sessionId },
- });
- try {
- this.chromeWs.send(detachMsg);
- } catch (err) {
- console.error('[inspect] Failed to detach session:', err);
- }
- }
-}
diff --git a/src/ios-actions.ts b/src/ios-actions.ts
deleted file mode 100644
index 8d246ca..0000000
--- a/src/ios-actions.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-/**
- * iOS command execution - mirrors actions.ts but for iOS Safari via Appium.
- * Provides 1:1 command parity where possible.
- */
-
-import type { IOSManager } from './ios-manager.js';
-import type { Command, Response } from './types.js';
-
-function successResponse(id: string, data: T): Response {
- return { id, success: true, data };
-}
-
-function errorResponse(id: string, error: string): Response {
- return { id, success: false, error };
-}
-
-/**
- * Execute a command on the iOS manager
- */
-export async function executeIOSCommand(command: Command, manager: IOSManager): Promise {
- const { id, action } = command;
-
- try {
- switch (action) {
- case 'launch': {
- const cmd = command as any;
- await manager.launch({
- device: cmd.device,
- udid: cmd.udid,
- });
- const info = manager.getDeviceInfo();
- return successResponse(id, {
- launched: true,
- device: info?.name ?? 'iOS Simulator',
- udid: info?.udid,
- });
- }
-
- case 'navigate': {
- const cmd = command as any;
- const result = await manager.navigate(cmd.url);
- return successResponse(id, result);
- }
-
- case 'click': {
- const cmd = command as any;
- await manager.click(cmd.selector);
- return successResponse(id, { clicked: true });
- }
-
- case 'tap': {
- const cmd = command as any;
- await manager.tap(cmd.selector);
- return successResponse(id, { tapped: true });
- }
-
- case 'type': {
- const cmd = command as any;
- await manager.type(cmd.selector, cmd.text, {
- delay: cmd.delay,
- clear: cmd.clear,
- });
- return successResponse(id, { typed: true });
- }
-
- case 'fill': {
- const cmd = command as any;
- await manager.fill(cmd.selector, cmd.value);
- return successResponse(id, { filled: true });
- }
-
- case 'screenshot': {
- const cmd = command as any;
- const result = await manager.screenshot({
- path: cmd.path,
- fullPage: cmd.fullPage,
- });
- return successResponse(id, result);
- }
-
- case 'snapshot': {
- const cmd = command as any;
- const result = await manager.getSnapshot({
- interactive: cmd.interactive,
- });
- return successResponse(id, { snapshot: result.tree, refs: result.refs });
- }
-
- case 'scroll': {
- const cmd = command as any;
- await manager.scroll({
- selector: cmd.selector,
- x: cmd.x,
- y: cmd.y,
- direction: cmd.direction,
- amount: cmd.amount,
- });
- return successResponse(id, { scrolled: true });
- }
-
- case 'swipe': {
- const cmd = command as any;
- await manager.swipe(cmd.direction, { distance: cmd.distance });
- return successResponse(id, { swiped: true });
- }
-
- case 'evaluate': {
- const cmd = command as any;
- const result = await manager.evaluate(cmd.script, ...(cmd.args ?? []));
- return successResponse(id, { result });
- }
-
- case 'wait': {
- const cmd = command as any;
- await manager.wait({
- selector: cmd.selector,
- timeout: cmd.timeout,
- state: cmd.state,
- });
- return successResponse(id, { waited: true });
- }
-
- case 'press': {
- const cmd = command as any;
- await manager.press(cmd.key);
- return successResponse(id, { pressed: true });
- }
-
- case 'hover': {
- const cmd = command as any;
- await manager.hover(cmd.selector);
- return successResponse(id, { hovered: true });
- }
-
- case 'content': {
- const cmd = command as any;
- const html = await manager.getContent(cmd.selector);
- return successResponse(id, { html });
- }
-
- case 'gettext': {
- const cmd = command as any;
- const text = await manager.getText(cmd.selector);
- return successResponse(id, { text });
- }
-
- case 'getattribute': {
- const cmd = command as any;
- const value = await manager.getAttribute(cmd.selector, cmd.attribute);
- return successResponse(id, { value });
- }
-
- case 'isvisible': {
- const cmd = command as any;
- const visible = await manager.isVisible(cmd.selector);
- return successResponse(id, { visible });
- }
-
- case 'isenabled': {
- const cmd = command as any;
- const enabled = await manager.isEnabled(cmd.selector);
- return successResponse(id, { enabled });
- }
-
- case 'url': {
- const url = await manager.getUrl();
- return successResponse(id, { url });
- }
-
- case 'title': {
- const title = await manager.getTitle();
- return successResponse(id, { title });
- }
-
- case 'back': {
- await manager.goBack();
- return successResponse(id, { navigated: 'back' });
- }
-
- case 'forward': {
- await manager.goForward();
- return successResponse(id, { navigated: 'forward' });
- }
-
- case 'reload': {
- await manager.reload();
- return successResponse(id, { reloaded: true });
- }
-
- case 'select': {
- const cmd = command as any;
- await manager.select(cmd.selector, cmd.values);
- return successResponse(id, { selected: true });
- }
-
- case 'check': {
- const cmd = command as any;
- await manager.check(cmd.selector);
- return successResponse(id, { checked: true });
- }
-
- case 'uncheck': {
- const cmd = command as any;
- await manager.uncheck(cmd.selector);
- return successResponse(id, { unchecked: true });
- }
-
- case 'focus': {
- const cmd = command as any;
- await manager.focus(cmd.selector);
- return successResponse(id, { focused: true });
- }
-
- case 'clear': {
- const cmd = command as any;
- await manager.clear(cmd.selector);
- return successResponse(id, { cleared: true });
- }
-
- case 'count': {
- const cmd = command as any;
- const count = await manager.count(cmd.selector);
- return successResponse(id, { count });
- }
-
- case 'boundingbox': {
- const cmd = command as any;
- const box = await manager.getBoundingBox(cmd.selector);
- return successResponse(id, { box });
- }
-
- case 'close': {
- await manager.close();
- return successResponse(id, { closed: true });
- }
-
- // iOS-specific: device list
- case 'device_list': {
- const devices = await manager.listDevices();
- return successResponse(id, { devices });
- }
-
- // Commands that don't apply to iOS Safari
- case 'tab_new':
- case 'tab_list':
- case 'tab_switch':
- case 'tab_close':
- case 'window_new':
- return errorResponse(
- id,
- `Command '${action}' is not supported on iOS Safari. Mobile Safari does not support programmatic tab management.`
- );
-
- case 'pdf':
- return errorResponse(id, 'PDF generation is not supported on iOS Safari.');
-
- case 'screencast_start':
- case 'screencast_stop':
- return errorResponse(id, 'Screencast is not supported on iOS (requires CDP).');
-
- case 'recording_start':
- case 'recording_stop':
- case 'recording_restart':
- return errorResponse(id, 'Video recording is not yet supported on iOS.');
-
- default:
- return errorResponse(id, `Unknown or unsupported iOS command: ${action}`);
- }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- return errorResponse(id, message);
- }
-}
diff --git a/src/ios-manager.test.ts b/src/ios-manager.test.ts
deleted file mode 100644
index a7851cf..0000000
--- a/src/ios-manager.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { IOSManager } from './ios-manager.js';
-
-// Mock node-simctl
-vi.mock('node-simctl', () => {
- return {
- Simctl: class MockSimctl {
- async getDevices() {
- return {
- 'iOS 18.0': [
- {
- name: 'iPhone 16 Pro',
- udid: 'TEST-UDID-1234',
- state: 'Shutdown',
- isAvailable: true,
- },
- {
- name: 'iPhone 16',
- udid: 'TEST-UDID-5678',
- state: 'Booted',
- isAvailable: true,
- },
- {
- name: 'iPad Pro',
- udid: 'TEST-UDID-IPAD',
- state: 'Shutdown',
- isAvailable: true,
- },
- ],
- };
- }
- },
- };
-});
-
-describe('IOSManager', () => {
- let manager: IOSManager;
-
- beforeEach(() => {
- manager = new IOSManager();
- });
-
- describe('listDevices', () => {
- it('should list available iOS simulators', async () => {
- const devices = await manager.listDevices();
-
- expect(devices).toHaveLength(3);
- expect(devices[0]).toEqual({
- name: 'iPhone 16 Pro',
- udid: 'TEST-UDID-1234',
- state: 'Shutdown',
- runtime: 'iOS 18.0',
- isAvailable: true,
- isRealDevice: false,
- });
- });
-
- it('should include runtime version for each device', async () => {
- const devices = await manager.listDevices();
-
- devices.forEach((device) => {
- expect(device.runtime).toBe('iOS 18.0');
- });
- });
- });
-
- describe('isLaunched', () => {
- it('should return false when browser is not launched', () => {
- expect(manager.isLaunched()).toBe(false);
- });
- });
-
- describe('getRefData', () => {
- it('should return null for unknown refs', () => {
- // Access private method via bracket notation for testing
- const result = (manager as any).getRefData('@e99');
- expect(result).toBeNull();
- });
-
- it('should handle @-prefixed refs', () => {
- // Set up a ref in the refMap
- (manager as any).refMap = {
- e1: { selector: 'button', role: 'button', name: 'Submit' },
- };
-
- const result = (manager as any).getRefData('@e1');
- expect(result).toEqual({ selector: 'button', role: 'button', name: 'Submit' });
- });
-
- it('should handle ref= prefixed refs', () => {
- (manager as any).refMap = {
- e2: { selector: 'a', role: 'link', name: 'Learn more' },
- };
-
- const result = (manager as any).getRefData('ref=e2');
- expect(result).toEqual({ selector: 'a', role: 'link', name: 'Learn more' });
- });
-
- it('should handle bare ref names', () => {
- (manager as any).refMap = {
- e3: { selector: 'input', role: 'textbox', name: 'Email' },
- };
-
- const result = (manager as any).getRefData('e3');
- expect(result).toEqual({ selector: 'input', role: 'textbox', name: 'Email' });
- });
- });
-});
-
-describe('IOSManager integration', () => {
- // These tests require Appium and iOS Simulator to be available
- // They are skipped by default and can be run manually
- describe.skip('with real simulator', () => {
- let manager: IOSManager;
-
- beforeEach(() => {
- // Use real implementation for integration tests
- vi.resetModules();
- manager = new IOSManager();
- });
-
- it('should launch Safari and navigate', async () => {
- await manager.launch({ device: 'iPhone 16 Pro' });
- expect(manager.isLaunched()).toBe(true);
-
- const result = await manager.navigate('https://example.com');
- expect(result.url).toContain('example.com');
- expect(result.title).toBe('Example Domain');
-
- await manager.close();
- }, 120000);
-
- it('should take screenshots', async () => {
- await manager.launch({ device: 'iPhone 16 Pro' });
- await manager.navigate('https://example.com');
-
- const result = await manager.screenshot();
- expect(result.base64).toBeDefined();
- expect(result.base64?.length).toBeGreaterThan(1000);
-
- await manager.close();
- }, 120000);
-
- it('should generate snapshots with refs', async () => {
- await manager.launch({ device: 'iPhone 16 Pro' });
- await manager.navigate('https://example.com');
-
- const snapshot = await manager.getSnapshot();
- expect(snapshot.tree).toContain('link');
- expect(snapshot.tree).toContain('[ref=e1]');
- expect(snapshot.refs.e1).toBeDefined();
- expect(snapshot.refs.e1.role).toBe('link');
-
- await manager.close();
- }, 120000);
- });
-});
diff --git a/src/ios-manager.ts b/src/ios-manager.ts
deleted file mode 100644
index 825d1e9..0000000
--- a/src/ios-manager.ts
+++ /dev/null
@@ -1,1299 +0,0 @@
-/**
- * iOS Simulator Manager - Manages iOS Simulator and Safari automation via Appium.
- *
- * This provides 1:1 command parity with BrowserManager for iOS Safari.
- */
-
-// Declare browser globals used in execute() callbacks - these run in browser context, not Node
-declare const document: any;
-declare const window: any;
-
-import { Simctl } from 'node-simctl';
-import { remote, type Browser as WDIOBrowser } from 'webdriverio';
-import { spawn, type ChildProcess } from 'node:child_process';
-import { existsSync } from 'node:fs';
-import path from 'node:path';
-import os from 'node:os';
-
-// Ref map for element targeting (mirrors snapshot.ts)
-export interface IOSRefMap {
- [ref: string]: {
- selector: string;
- role?: string;
- name?: string;
- xpath?: string;
- };
-}
-
-export interface IOSEnhancedSnapshot {
- tree: string;
- refs: IOSRefMap;
-}
-
-interface ConsoleMessage {
- type: string;
- text: string;
- timestamp: number;
-}
-
-interface IOSDeviceInfo {
- name: string;
- udid: string;
- state: string;
- runtime: string;
- isAvailable: boolean;
- isRealDevice?: boolean;
-}
-
-/**
- * Manages iOS Simulator and Safari automation via Appium
- */
-export class IOSManager {
- private simctl: Simctl;
- private browser: WDIOBrowser | null = null;
- private appiumProcess: ChildProcess | null = null;
- private deviceUdid: string | null = null;
- private deviceName: string | null = null;
- private consoleMessages: ConsoleMessage[] = [];
- private refMap: IOSRefMap = {};
- private lastSnapshot: string = '';
- private refCounter: number = 0;
-
- // Default Appium port
- private static readonly APPIUM_PORT = 4723;
- private static readonly APPIUM_HOST = '127.0.0.1';
-
- constructor() {
- this.simctl = new Simctl();
- }
-
- /**
- * Check if browser is launched
- */
- isLaunched(): boolean {
- return this.browser !== null;
- }
-
- /**
- * List connected real iOS devices
- */
- private async listRealDevices(): Promise {
- const devices: IOSDeviceInfo[] = [];
-
- try {
- // Use xcrun xctrace to list connected devices
- const { execSync } = await import('node:child_process');
- const output = execSync('xcrun xctrace list devices 2>/dev/null || true', {
- encoding: 'utf-8',
- timeout: 10000,
- });
-
- // Parse output - format is:
- // == Devices ==
- // Device Name (OS Version) (UDID)
- // Real devices show version as just "26.2", simulators as "iOS 18.0"
- const lines = output.split('\n');
- let inDevicesSection = false;
-
- for (const line of lines) {
- if (line.includes('== Devices ==')) {
- inDevicesSection = true;
- continue;
- }
- // Stop at Simulators or Devices Offline section
- if (line.includes('== Simulators ==') || line.includes('== Devices Offline ==')) {
- break;
- }
-
- if (inDevicesSection && line.trim()) {
- // Match pattern: "Device Name (version) (UDID)"
- const match = line.match(/^(.+?)\s+\(([^)]+)\)\s+\(([A-F0-9-]+)\)$/i);
- if (match) {
- const [, name, version, udid] = match;
- const nameLower = name.toLowerCase();
- // Include iOS devices: either name contains iPhone/iPad, or version looks like iOS
- // (a simple version number like "26.2" or "18.6") and isn't a Mac
- const isIOS =
- nameLower.includes('iphone') ||
- nameLower.includes('ipad') ||
- version.includes('iOS') ||
- version.includes('iPadOS');
- const isMac =
- nameLower.includes('mac') ||
- nameLower.includes('macbook') ||
- nameLower.includes('imac');
-
- if (isIOS || (!isMac && /^\d+\.\d+(\.\d+)?$/.test(version))) {
- devices.push({
- name: name.trim(),
- udid: udid,
- state: 'Connected',
- runtime: `iOS ${version}`,
- isAvailable: true,
- isRealDevice: true,
- });
- }
- }
- }
- }
- } catch {
- // Ignore errors - real device listing is optional
- }
-
- return devices;
- }
-
- /**
- * List available iOS simulators
- */
- async listDevices(): Promise {
- const devices: IOSDeviceInfo[] = [];
-
- try {
- const rawDevices = await this.simctl.getDevices();
-
- for (const [runtime, deviceList] of Object.entries(rawDevices)) {
- if (!Array.isArray(deviceList)) continue;
-
- for (const device of deviceList) {
- // Only include iPhone and iPad simulators
- if (device.name && (device.name.includes('iPhone') || device.name.includes('iPad'))) {
- devices.push({
- name: device.name,
- udid: device.udid,
- state: device.state,
- runtime: runtime,
- isAvailable: device.isAvailable ?? true,
- isRealDevice: false,
- });
- }
- }
- }
- } catch (error) {
- throw new Error(
- `Failed to list iOS simulators. Is Xcode installed? Error: ${error instanceof Error ? error.message : String(error)}`
- );
- }
-
- return devices;
- }
-
- /**
- * List all devices (simulators + real devices)
- */
- async listAllDevices(): Promise