Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
142 changes: 142 additions & 0 deletions packages/core/src/agents/browser/automationOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* @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.
*/

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

const OVERLAY_ELEMENT_ID = '__gemini_automation_overlay';
const OVERLAY_STYLE_ID = '__gemini_automation_style';

/**
* JavaScript code to inject the automation overlay
*/
const OVERLAY_INJECTION_SCRIPT = `
(() => {
// Remove existing overlay if present
const existingOverlay = document.getElementById('${OVERLAY_ELEMENT_ID}');
if (existingOverlay) {
existingOverlay.remove();
}

const existingStyle = document.getElementById('${OVERLAY_STYLE_ID}');
if (existingStyle) {
existingStyle.remove();
}

// Create style element with animation
const style = document.createElement('style');
style.id = '${OVERLAY_STYLE_ID}';
style.textContent = \`
@keyframes __gemini_pulse {
0%, 100% {
border-color: rgba(66, 133, 244, 0.2);
box-shadow: inset 0 0 8px rgba(66, 133, 244, 0.1);
}
50% {
border-color: rgba(66, 133, 244, 0.6);
box-shadow: inset 0 0 16px rgba(66, 133, 244, 0.2);
}
}
\`;
document.head.appendChild(style);

// Create overlay element
const overlay = document.createElement('div');
overlay.id = '${OVERLAY_ELEMENT_ID}';
overlay.setAttribute('aria-hidden', 'true');
overlay.setAttribute('role', 'presentation');
overlay.style.cssText = \`
position: fixed;
inset: 0;
z-index: 2147483647;
pointer-events: none;
border: 3px solid rgba(66, 133, 244, 0.4);
animation: __gemini_pulse 2s ease-in-out infinite;
\`;

document.documentElement.appendChild(overlay);

return 'Automation overlay injected successfully';
})();
`;

/**
* JavaScript code to remove the automation overlay
*/
const OVERLAY_REMOVAL_SCRIPT = `
(() => {
const overlay = document.getElementById('${OVERLAY_ELEMENT_ID}');
if (overlay) {
overlay.remove();
}

const style = document.getElementById('${OVERLAY_STYLE_ID}');
if (style) {
style.remove();
}

return 'Automation overlay removed successfully';
})();
`;

/**
* 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',
{ code: OVERLAY_INJECTION_SCRIPT },
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',
{ code: OVERLAY_REMOVAL_SCRIPT },
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
16 changes: 15 additions & 1 deletion 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,9 +62,22 @@ 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);
const mcpTools = await createMcpDeclarativeTools(
browserManager,
messageBus,
config,
);
const availableToolNames = mcpTools.map((t) => t.name);

// Validate required semantic tools are available
Expand Down
15 changes: 15 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 { removeAutomationOverlay } 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', () => ({
removeAutomationOverlay: 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(removeAutomationOverlay).mockClear();

// Setup mock config
mockConfig = makeFakeConfig({
Expand Down Expand Up @@ -410,5 +416,14 @@ describe('BrowserManager', () => {

expect(client.close).toHaveBeenCalled();
});

it('should remove automation overlay during cleanup', async () => {
const manager = new BrowserManager(mockConfig);
await manager.getRawMcpClient(); // Ensure connected

await manager.close();

expect(removeAutomationOverlay).toHaveBeenCalledWith(manager);
});
});
});
12 changes: 12 additions & 0 deletions packages/core/src/agents/browser/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { debugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js';
import { Storage } from '../../config/storage.js';
import * as path from 'node:path';
import { removeAutomationOverlay } from './automationOverlay.js';

// Pin chrome-devtools-mcp version for reproducibility.
const CHROME_DEVTOOLS_MCP_VERSION = '0.17.1';
Expand Down Expand Up @@ -187,6 +188,17 @@ export class BrowserManager {
* the transport will terminate the browser.
*/
async close(): Promise<void> {
// Remove automation overlay before closing
if (this.rawMcpClient) {
try {
await removeAutomationOverlay(this);
} catch (error) {
debugLogger.error(
`Error removing automation overlay: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

// Close MCP client first
if (this.rawMcpClient) {
try {
Expand Down
Loading