Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions packages/core/src/agents/browser/automationOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @fileoverview Automation overlay utilities for visual indication during browser automation.
*
* Provides functions to inject and remove a pulsating blue border overlay
* that indicates when the browser is under AI agent control.
*
* Uses the Web Animations API instead of injected <style> tags so the
* animation works on sites with strict Content Security Policies (e.g. google.com).
*
* The script strings are passed to chrome-devtools-mcp's evaluate_script tool
* which expects a plain function expression (NOT an IIFE).
*/

import type { BrowserManager } from './browserManager.js';
import { debugLogger } from '../../utils/debugLogger.js';

const OVERLAY_ELEMENT_ID = '__gemini_automation_overlay';

/**
* Builds the JavaScript function string that injects the automation overlay.
*
* Returns a plain arrow-function expression (no trailing invocation) because
* chrome-devtools-mcp's evaluate_script tool invokes it internally.
*
* Avoids nested template literals by using string concatenation for cssText.
*/
function buildInjectionScript(): string {
// Build the script as a plain string – no nested template literals.
return [
'() => {',
` const id = '${OVERLAY_ELEMENT_ID}';`,
' const existing = document.getElementById(id);',
' if (existing) existing.remove();',
'',
' const overlay = document.createElement("div");',
' overlay.id = id;',
' overlay.setAttribute("aria-hidden", "true");',
' overlay.setAttribute("role", "presentation");',
'',
' overlay.style.position = "fixed";',
' overlay.style.top = "0";',
' overlay.style.left = "0";',
' overlay.style.right = "0";',
' overlay.style.bottom = "0";',
' overlay.style.zIndex = "2147483647";',
' overlay.style.pointerEvents = "none";',
' overlay.style.border = "6px solid rgba(66, 133, 244, 1.0)";',
'',
' document.documentElement.appendChild(overlay);',
'',
' try {',
' overlay.animate([',
' { borderColor: "rgba(66,133,244,0.3)", boxShadow: "inset 0 0 8px rgba(66,133,244,0.15)" },',
' { borderColor: "rgba(66,133,244,1.0)", boxShadow: "inset 0 0 16px rgba(66,133,244,0.5)" },',
' { borderColor: "rgba(66,133,244,0.3)", boxShadow: "inset 0 0 8px rgba(66,133,244,0.15)" }',
' ], { duration: 2000, iterations: Infinity, easing: "ease-in-out" });',
' } catch (e) {}',
'',
' return "overlay-injected";',
'}',
].join('\n');
}

/**
* Builds the JavaScript function string that removes the automation overlay.
*/
function buildRemovalScript(): string {
return [
'() => {',
` var el = document.getElementById('${OVERLAY_ELEMENT_ID}');`,
' if (el) el.remove();',
' return "overlay-removed";',
'}',
].join('\n');
}
Comment on lines +23 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a fixed ID for the automation overlay makes it easily detectable by websites, which could lead to the browser agent being blocked or its behavior altered. To improve robustness and make detection harder, I suggest using a randomized ID for the overlay. This can be achieved by using a constant prefix and appending a random string.

const OVERLAY_ID_PREFIX = '__gemini_automation_overlay_';

/**
 * Builds the JavaScript function string that injects the automation overlay.
 *
 * Returns a plain arrow-function expression (no trailing invocation) because
 * chrome-devtools-mcp's evaluate_script tool invokes it internally.
 *
 * Avoids nested template literals by using string concatenation for cssText.
 */
function buildInjectionScript(): string {
  return `() => {
    const prefix = '${OVERLAY_ID_PREFIX}';
    // Remove any existing overlays to be safe.
    document.querySelectorAll(`[id^="${prefix}"]`).forEach(el => el.remove());

    const overlay = document.createElement('div');
    overlay.id = prefix + Math.random().toString(36).slice(2);
    overlay.setAttribute('aria-hidden', 'true');
    overlay.setAttribute('role', 'presentation');

    Object.assign(overlay.style, {
      position: 'fixed',
      top: '0',
      left: '0',
      right: '0',
      bottom: '0',
      zIndex: '2147483647',
      pointerEvents: 'none',
      border: '6px solid rgba(66, 133, 244, 1.0)',
    });

    document.documentElement.appendChild(overlay);

    try {
      overlay.animate([
        { borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' },
        { borderColor: 'rgba(66,133,244,1.0)', boxShadow: 'inset 0 0 16px rgba(66,133,244,0.5)' },
        { borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' }
      ], { duration: 2000, iterations: Infinity, easing: 'ease-in-out' });
    } catch (e) {
      // Silently ignore animation errors, as they can happen on sites with strict CSP.
      // The border itself is the most important visual indicator.
    }

    return 'overlay-injected';
  }`;
}

/**
 * Builds the JavaScript function string that removes the automation overlay.
 */
function buildRemovalScript(): string {
  return `() => {
    const prefix = '${OVERLAY_ID_PREFIX}';
    document.querySelectorAll(`[id^="${prefix}"]`).forEach(el => el.remove());
    return 'overlay-removed';
  }`;
}


/**
* Injects the automation overlay into the current page.
*/
export async function injectAutomationOverlay(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
debugLogger.log('Injecting automation overlay...');

const result = await browserManager.callTool(
'evaluate_script',
{ function: buildInjectionScript() },
signal,
);

if (result.isError) {
debugLogger.warn('Failed to inject automation overlay:', result);
} else {
debugLogger.log('Automation overlay injected successfully');
}
} catch (error) {
debugLogger.warn('Error injecting automation overlay:', error);
}
}

/**
* Removes the automation overlay from the current page.
*/
export async function removeAutomationOverlay(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
debugLogger.log('Removing automation overlay...');

const result = await browserManager.callTool(
'evaluate_script',
{ function: buildRemovalScript() },
signal,
);

if (result.isError) {
debugLogger.warn('Failed to remove automation overlay:', result);
} else {
debugLogger.log('Automation overlay removed successfully');
}
} catch (error) {
debugLogger.warn('Error removing automation overlay:', error);
}
}
29 changes: 29 additions & 0 deletions packages/core/src/agents/browser/browserAgentFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
} from './browserAgentFactory.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
Expand All @@ -35,6 +36,10 @@ vi.mock('./browserManager.js', () => ({
BrowserManager: vi.fn(() => mockBrowserManager),
}));

vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('../../utils/debugLogger.js', () => ({
debugLogger: {
log: vi.fn(),
Expand All @@ -55,6 +60,8 @@ describe('browserAgentFactory', () => {
beforeEach(() => {
vi.clearAllMocks();

vi.mocked(injectAutomationOverlay).mockClear();

// Reset mock implementations
mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
Expand Down Expand Up @@ -99,6 +106,28 @@ describe('browserAgentFactory', () => {
expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
});

it('should inject automation overlay when not in headless mode', async () => {
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
expect(injectAutomationOverlay).toHaveBeenCalledWith(mockBrowserManager);
});

it('should not inject automation overlay when in headless mode', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
},
},
});
await createBrowserAgentDefinition(headlessConfig, mockMessageBus);
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});

it('should return agent definition with discovered tools', async () => {
const { definition } = await createBrowserAgentDefinition(
mockConfig,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/agents/browser/browserAgentFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './browserAgentDefinition.js';
import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { debugLogger } from '../../utils/debugLogger.js';

/**
Expand Down Expand Up @@ -61,6 +62,15 @@ export async function createBrowserAgentDefinition(
printOutput('Browser connected with isolated MCP client.');
}

// Inject automation overlay if not in headless mode
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig?.customConfig?.headless) {
if (printOutput) {
printOutput('Injecting automation overlay...');
}
await injectAutomationOverlay(browserManager);
}

// Create declarative tools from dynamically discovered MCP tools
// These tools dispatch to browserManager's isolated client
const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/agents/browser/browserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserManager } from './browserManager.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import { injectAutomationOverlay } from './automationOverlay.js';

// Mock the MCP SDK
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Expand Down Expand Up @@ -42,6 +43,10 @@ vi.mock('../../utils/debugLogger.js', () => ({
},
}));

vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

Expand All @@ -50,6 +55,7 @@ describe('BrowserManager', () => {

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();

// Setup mock config
mockConfig = makeFakeConfig({
Expand Down Expand Up @@ -411,4 +417,81 @@ describe('BrowserManager', () => {
expect(client.close).toHaveBeenCalled();
});
});

describe('overlay re-injection in callTool', () => {
it('should re-inject overlay after click in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: '1_2' });

expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});

it('should re-inject overlay after navigate_page in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('navigate_page', { url: 'https://example.com' });

expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});

it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'click_at',
'new_page',
'press_key',
'handle_dialog',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
}
});

it('should NOT re-inject overlay after read-only tools', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'take_snapshot',
'take_screenshot',
'get_console_message',
'fill',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).not.toHaveBeenCalled();
}
});

it('should NOT re-inject overlay when headless is true', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { headless: true },
},
});
const manager = new BrowserManager(headlessConfig);
await manager.callTool('click', { uid: '1_2' });

expect(injectAutomationOverlay).not.toHaveBeenCalled();
});

it('should NOT re-inject overlay when tool returns an error result', async () => {
vi.mocked(Client).mockImplementation(
() =>
({
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listTools: vi.fn().mockResolvedValue({ tools: [] }),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Element not found' }],
isError: true,
}),
}) as unknown as InstanceType<typeof Client>,
);

const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: 'bad' });

expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
});
Loading