/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
/**
 * @fileoverview Manages browser lifecycle for the Browser Agent.
 *
 * Handles:
 * - Browser management via chrome-devtools-mcp with --isolated mode
 * - CDP connection via raw MCP SDK Client (NOT registered in main registry)
 * - Visual tools via --experimental-vision flag
 *
 * IMPORTANT: The MCP client here is ISOLATED from the main agent's tool registry.
 * Tools discovered from chrome-devtools-mcp are NOT registered in the main registry.
 * They are wrapped as DeclarativeTools and passed directly to the browser agent.
 */
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { coreEvents } from '../../utils/events.js';
import { Storage } from '../../config/storage.js';
import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js';
import { injectInputBlocker } from './inputBlocker.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { injectAutomationOverlay } from './automationOverlay.js';
import { logBrowserAgentConnection } from '../../telemetry/loggers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Default browser profile directory name within ~/.gemini/
const BROWSER_PROFILE_DIR = 'cli-browser-profile';
/**
 * Typed error for domain restriction violations.
 * Thrown when a navigation tool targets a domain not in allowedDomains.
 * Caught by mcpToolWrapper to terminate the agent immediately.
 */
export class DomainNotAllowedError extends Error {
    constructor(message) {
        super(message);
        this.name = 'DomainNotAllowedError';
    }
}
// Default timeout for MCP operations
const MCP_TIMEOUT_MS = 60_000;
// Maximum reconnection attempts before giving up
const MAX_RECONNECT_RETRIES = 3;
// Base delay (ms) for exponential backoff between reconnection attempts
const RECONNECT_BASE_DELAY_MS = 500;
/**
 * Tools that can cause a full-page navigation (explicitly or implicitly).
 *
 * When any of these completes successfully, the current page DOM is replaced
 * and the injected automation overlay is lost. BrowserManager re-injects the
 * overlay after every successful call to one of these tools.
 *
 * Note: chrome-devtools-mcp is a pure request/response server and emits no
 * MCP notifications, so listening for page-load events via the protocol is
 * not possible. Intercepting at callTool() is the equivalent mechanism.
 */
const POTENTIALLY_NAVIGATING_TOOLS = new Set([
    'click', // clicking a link navigates
    'click_at', // coordinate click can also follow a link
    'navigate_page',
    'new_page',
    'select_page', // switching pages can lose the overlay
    'press_key', // Enter on a focused link/form triggers navigation
    'handle_dialog', // confirming beforeunload can trigger navigation
]);
/**
 * Manages browser lifecycle and ISOLATED MCP client for the Browser Agent.
 *
 * The browser is launched and managed by chrome-devtools-mcp in --isolated mode.
 * Visual tools (click_at, etc.) are enabled via --experimental-vision flag.
 *
 * Key isolation property: The MCP client here does NOT register tools
 * in the main ToolRegistry. Tools are kept local to the browser agent.
 */
export class BrowserManager {
    config;
    // --- Static singleton management ---
    static instances = new Map();
    /**
     * Returns the cache key for a given config.
     * Uses `sessionMode:profilePath` so different profiles get separate instances.
     */
    static getInstanceKey(config) {
        const browserConfig = config.getBrowserAgentConfig();
        const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
        const profilePath = browserConfig.customConfig.profilePath ?? 'default';
        return `${sessionMode}:${profilePath}`;
    }
    /**
     * Returns an existing BrowserManager for the current config's session mode
     * and profile, or creates a new one.
     */
    static getInstance(config) {
        const key = BrowserManager.getInstanceKey(config);
        let instance = BrowserManager.instances.get(key);
        if (!instance) {
            instance = new BrowserManager(config);
            BrowserManager.instances.set(key, instance);
            debugLogger.log(`Created new BrowserManager singleton (key: ${key})`);
        }
        else {
            debugLogger.log(`Reusing existing BrowserManager singleton (key: ${key})`);
        }
        return instance;
    }
    /**
     * Closes all cached BrowserManager instances and clears the cache.
     * Called on /clear commands and CLI exit.
     */
    static async resetAll() {
        const results = await Promise.allSettled(Array.from(BrowserManager.instances.values()).map((instance) => instance.close()));
        for (const result of results) {
            if (result.status === 'rejected') {
                debugLogger.error(`Error during BrowserManager cleanup: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
            }
        }
        BrowserManager.instances.clear();
    }
    /**
     * Alias for resetAll — used by CLI exit cleanup for clarity.
     */
    static async closeAll() {
        await BrowserManager.resetAll();
    }
    // --- Instance state ---
    // Raw MCP SDK Client - NOT the wrapper McpClient
    rawMcpClient;
    mcpTransport;
    discoveredTools = [];
    disconnected = false;
    isClosing = false;
    connectionPromise;
    /** State for action rate limiting */
    actionCounter = 0;
    maxActionsPerTask;
    /**
     * Whether to inject the automation overlay.
     * Always false in headless mode (no visible window to decorate).
     */
    shouldInjectOverlay;
    shouldDisableInput;
    constructor(config) {
        this.config = config;
        const browserConfig = config.getBrowserAgentConfig();
        this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
        this.shouldDisableInput = config.shouldDisableBrowserUserInput();
        this.maxActionsPerTask =
            browserConfig?.customConfig.maxActionsPerTask ?? 100;
    }
    /**
     * Gets the raw MCP SDK Client for direct tool calls.
     * This client is ISOLATED from the main tool registry.
     */
    async getRawMcpClient() {
        if (this.rawMcpClient) {
            return this.rawMcpClient;
        }
        await this.ensureConnection();
        if (!this.rawMcpClient) {
            throw new Error('Failed to initialize chrome-devtools MCP client');
        }
        return this.rawMcpClient;
    }
    /**
     * Gets the tool definitions discovered from the MCP server.
     * These are dynamically fetched from chrome-devtools-mcp.
     */
    async getDiscoveredTools() {
        await this.ensureConnection();
        return this.discoveredTools;
    }
    /**
     * Calls a tool on the MCP server.
     *
     * @param toolName The name of the tool to call
     * @param args Arguments to pass to the tool
     * @param signal Optional AbortSignal to cancel the call
     * @param isInternal Determine if the tool is for internal execution
     * @returns The result from the MCP server
     */
    async callTool(toolName, args, signal, isInternal = false) {
        if (signal?.aborted) {
            throw signal.reason ?? new Error('Operation cancelled');
        }
        // Hard enforcement of per-action rate limit
        if (!isInternal) {
            if (this.actionCounter >= this.maxActionsPerTask) {
                const error = new Error(`Browser agent reached maximum action limit (${this.maxActionsPerTask}). ` +
                    `Task terminated to prevent runaway execution. To config the limit, use maxActionsPerTask in the settings.`);
                throw error;
            }
            this.actionCounter++;
        }
        const errorMessage = this.checkNavigationRestrictions(toolName, args);
        if (errorMessage) {
            throw new DomainNotAllowedError(errorMessage);
        }
        const client = await this.getRawMcpClient();
        const callPromise = client.callTool({ name: toolName, arguments: args }, undefined, { timeout: MCP_TIMEOUT_MS });
        let result;
        // If no signal, just await directly
        if (!signal) {
            result = this.toResult(await callPromise);
        }
        else {
            // Race the call against the abort signal
            let onAbort;
            try {
                const raw = await Promise.race([
                    callPromise,
                    new Promise((_resolve, reject) => {
                        onAbort = () => reject(signal.reason ?? new Error('Operation cancelled'));
                        signal.addEventListener('abort', onAbort, { once: true });
                    }),
                ]);
                result = this.toResult(raw);
            }
            finally {
                if (onAbort) {
                    signal.removeEventListener('abort', onAbort);
                }
            }
        }
        // Re-inject the automation overlay and input blocker after tools that
        // can cause a full-page navigation. chrome-devtools-mcp emits no MCP
        // notifications, so callTool() is the only interception point.
        //
        // The input blocker injection is idempotent: the injected function
        // reuses the existing DOM element when present and only recreates
        // it when navigation has actually replaced the page DOM.
        if (!result.isError &&
            POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
            !signal?.aborted) {
            // Don't re-inject if explicitly switching to a page in the background
            if (toolName === 'select_page' && args['bringToFront'] === false) {
                return result;
            }
            try {
                if (this.shouldInjectOverlay) {
                    await injectAutomationOverlay(this, signal);
                }
                if (this.shouldDisableInput) {
                    await injectInputBlocker(this, signal);
                }
            }
            catch {
                // Never let overlay/blocker failures interrupt the tool result
            }
        }
        return result;
    }
    /**
     * Safely maps a raw MCP SDK callTool response to our typed McpToolCallResult
     * without using unsafe type assertions.
     */
    toResult(raw) {
        return {
            content: Array.isArray(raw.content)
                ? raw.content.map((item) => ({
                    type: item.type === 'image' ? 'image' : 'text',
                    text: item.text,
                    data: item.data,
                    mimeType: item.mimeType,
                }))
                : undefined,
            isError: raw.isError === true,
        };
    }
    /**
     * Returns whether the MCP client is currently connected and healthy.
     */
    isConnected() {
        return this.rawMcpClient !== undefined && !this.disconnected;
    }
    /**
     * Ensures browser and MCP client are connected.
     * If a previous connection was lost (e.g., user closed the browser),
     * this will reconnect with exponential backoff (up to MAX_RECONNECT_RETRIES).
     *
     * Concurrent callers share a single in-flight connection promise so that
     * two subagents racing at startup do not trigger duplicate connectMcp() calls.
     */
    async ensureConnection() {
        // Already connected and healthy — nothing to do
        if (this.isConnected()) {
            return;
        }
        // A connection is already being established — wait for it instead of racing
        if (this.connectionPromise) {
            return this.connectionPromise;
        }
        // If previously connected but transport died, clean up before reconnecting
        if (this.disconnected) {
            debugLogger.log('Previous browser connection was lost. Cleaning up before reconnecting...');
            await this.close();
            this.disconnected = false;
        }
        // Start connecting; store the promise so concurrent callers can join it
        this.connectionPromise = this.connectWithRetry().finally(() => {
            this.connectionPromise = undefined;
        });
        return this.connectionPromise;
    }
    /**
     * Connects to chrome-devtools-mcp with exponential backoff retry.
     */
    async connectWithRetry() {
        // Request browser consent if needed (first-run privacy notice)
        const consentGranted = await getBrowserConsentIfNeeded();
        if (!consentGranted) {
            throw new Error('Browser agent requires user consent to proceed. ' +
                'Please re-run and accept the privacy notice.');
        }
        let lastError;
        for (let attempt = 0; attempt < MAX_RECONNECT_RETRIES; attempt++) {
            try {
                await this.connectMcp();
                return;
            }
            catch (error) {
                lastError = error instanceof Error ? error : new Error(String(error));
                if (attempt < MAX_RECONNECT_RETRIES - 1) {
                    const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt);
                    debugLogger.log(`Connection attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
                    await new Promise((resolve) => setTimeout(resolve, delay));
                }
            }
        }
        throw lastError;
    }
    /**
     * Closes browser and cleans up connections.
     * The browser process is managed by chrome-devtools-mcp, so closing
     * the transport will terminate the browser.
     */
    async close() {
        this.isClosing = true;
        // Close MCP client first
        if (this.rawMcpClient) {
            try {
                await this.rawMcpClient.close();
            }
            catch (error) {
                debugLogger.error(`Error closing MCP client: ${error instanceof Error ? error.message : String(error)}`);
            }
            this.rawMcpClient = undefined;
        }
        // Close transport (this terminates the browser)
        if (this.mcpTransport) {
            try {
                await this.mcpTransport.close();
            }
            catch (error) {
                debugLogger.error(`Error closing MCP transport: ${error instanceof Error ? error.message : String(error)}`);
            }
            this.mcpTransport = undefined;
        }
        this.discoveredTools = [];
        this.connectionPromise = undefined;
    }
    /**
     * Connects to chrome-devtools-mcp which manages the browser process.
     *
     * Spawns node with the bundled chrome-devtools-mcp.mjs.
     * - --experimental-vision: Enables visual tools (click_at, etc.)
     *
     * IMPORTANT: This does NOT use McpClientManager and does NOT register
     * tools in the main ToolRegistry. The connection is isolated to this
     * BrowserManager instance.
     */
    async connectMcp() {
        this.isClosing = false;
        debugLogger.log('Connecting isolated MCP client to chrome-devtools-mcp...');
        // Create raw MCP SDK Client (not the wrapper McpClient)
        this.rawMcpClient = new Client({
            name: 'gemini-cli-browser-agent',
            version: '1.0.0',
        }, {
            capabilities: {},
        });
        // Build args for chrome-devtools-mcp
        const browserConfig = this.config.getBrowserAgentConfig();
        const rawSessionMode = browserConfig.customConfig.sessionMode;
        let sessionMode = rawSessionMode === 'isolated' || rawSessionMode === 'existing'
            ? rawSessionMode
            : 'persistent';
        // Detect sandbox environment.
        // SANDBOX env var is set to 'sandbox-exec' (seatbelt) or the container
        // name (Docker/Podman/gVisor/LXC) when running inside a sandbox.
        // CI uses 'sandbox:none' as a metadata label — not a real sandbox.
        const sandboxType = process.env['SANDBOX'];
        const isContainerSandbox = !!sandboxType &&
            sandboxType !== 'sandbox-exec' &&
            sandboxType !== 'sandbox:none';
        const isSeatbeltSandbox = sandboxType === 'sandbox-exec' && sessionMode !== 'existing';
        // Seatbelt sandbox: force isolated + headless for filesystem compatibility.
        // Chrome exists on the host, but persistent profiles may conflict with
        // seatbelt restrictions. Isolated mode uses tmpdir (always writable).
        if (isSeatbeltSandbox) {
            if (sessionMode !== 'isolated') {
                sessionMode = 'isolated';
                coreEvents.emitFeedback('info', '🔒 Sandbox: Using isolated browser session for compatibility.');
            }
        }
        const mcpArgs = ['--experimental-vision'];
        // Session mode determines how the browser is managed:
        // - "isolated": Temp profile, cleaned up after session (--isolated)
        // - "persistent": Persistent profile at ~/.gemini/cli-browser-profile/ (default)
        // - "existing": Connect to already-running Chrome (--autoConnect, requires
        //   remote debugging enabled at chrome://inspect/#remote-debugging)
        if (sessionMode === 'isolated') {
            mcpArgs.push('--isolated');
        }
        else if (sessionMode === 'existing') {
            if (isContainerSandbox) {
                // In container sandboxes, --autoConnect can't discover Chrome on the
                // host (it uses local pipes/sockets). Use --browser-url with the
                // resolved IP of host.docker.internal instead of the hostname, because
                // Chrome's DevTools protocol rejects HTTP requests where the Host
                // header is not 'localhost' or an IP address.
                const dns = await import('node:dns');
                let browserHost = 'host.docker.internal';
                try {
                    const { address } = await dns.promises.lookup(browserHost);
                    browserHost = address;
                }
                catch {
                    // Fallback: use hostname as-is if DNS resolution fails
                    debugLogger.log(`Could not resolve host.docker.internal, using hostname directly`);
                }
                const browserUrl = `http://${browserHost}:9222`;
                mcpArgs.push('--browser-url', browserUrl);
                coreEvents.emitFeedback('info', `🔒 Container sandbox: Connecting to Chrome via ${browserHost}:9222.`);
            }
            else {
                mcpArgs.push('--autoConnect');
                const message = '🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.';
                coreEvents.emitFeedback('info', message);
                coreEvents.emitConsoleLog('info', message);
            }
        }
        // Add optional settings from config.
        // Force headless in seatbelt sandbox since Chrome profile/display access
        // may be restricted, and the user is running in a sandboxed environment.
        const effectiveHeadless = !!browserConfig.customConfig.headless || isSeatbeltSandbox;
        if (effectiveHeadless) {
            mcpArgs.push('--headless');
        }
        if (browserConfig.customConfig.profilePath) {
            mcpArgs.push('--userDataDir', browserConfig.customConfig.profilePath);
        }
        else if (sessionMode === 'persistent') {
            // Default persistent profile lives under ~/.gemini/cli-browser-profile
            const defaultProfilePath = path.join(Storage.getGlobalGeminiDir(), BROWSER_PROFILE_DIR);
            mcpArgs.push('--userDataDir', defaultProfilePath);
        }
        // Respect the user's privacy.usageStatisticsEnabled setting
        if (!this.config.getUsageStatisticsEnabled()) {
            mcpArgs.push('--no-usage-statistics', '--no-performance-crux');
        }
        if (browserConfig.customConfig.allowedDomains &&
            browserConfig.customConfig.allowedDomains.length > 0) {
            const exclusionRules = browserConfig.customConfig.allowedDomains
                .map((domain) => {
                if (!/^(\*\.)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/.test(domain)) {
                    throw new Error(`Invalid domain in allowedDomains: ${domain}`);
                }
                return `EXCLUDE ${domain}`;
            })
                .join(', ');
            mcpArgs.push(`--chromeArg="--host-rules=MAP * ~NOTFOUND, ${exclusionRules}"`);
        }
        debugLogger.log(`Launching bundled chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`);
        // Create stdio transport to the bundled chrome-devtools-mcp.
        // stderr is piped (not inherited) to prevent MCP server banners and
        // warnings from corrupting the UI in alternate buffer mode.
        let bundleMcpPath = path.resolve(__dirname, 'bundled/chrome-devtools-mcp.mjs');
        if (!fs.existsSync(bundleMcpPath)) {
            bundleMcpPath = path.resolve(__dirname, __dirname.includes(`${path.sep}dist${path.sep}`)
                ? '../../../bundled/chrome-devtools-mcp.mjs'
                : '../../../dist/bundled/chrome-devtools-mcp.mjs');
        }
        this.mcpTransport = new StdioClientTransport({
            command: 'node',
            args: [bundleMcpPath, ...mcpArgs],
            stderr: 'pipe',
        });
        // Forward piped stderr to debugLogger so it's visible with --debug.
        const stderrStream = this.mcpTransport.stderr;
        if (stderrStream) {
            stderrStream.on('data', (chunk) => {
                debugLogger.log(`[chrome-devtools-mcp stderr] ${chunk.toString().trimEnd()}`);
            });
        }
        this.mcpTransport.onclose = () => {
            this.disconnected = true;
            if (this.isClosing) {
                return;
            }
            debugLogger.error('chrome-devtools-mcp transport closed unexpectedly. ' +
                'The MCP server process may have crashed.');
        };
        this.mcpTransport.onerror = (error) => {
            debugLogger.error(`chrome-devtools-mcp transport error: ${error.message}`);
        };
        // Connect to MCP server — use a shorter timeout for 'existing' mode
        // since it should connect quickly if remote debugging is enabled.
        const connectTimeoutMs = sessionMode === 'existing' ? 15_000 : MCP_TIMEOUT_MS;
        let timeoutId;
        const connectStartMs = Date.now();
        try {
            await Promise.race([
                (async () => {
                    await this.rawMcpClient.connect(this.mcpTransport);
                    debugLogger.log('MCP client connected to chrome-devtools-mcp');
                    await this.discoverTools();
                    // clear the action counter for each connection
                    this.actionCounter = 0;
                    logBrowserAgentConnection(this.config, Date.now() - connectStartMs, {
                        session_mode: sessionMode,
                        headless: effectiveHeadless,
                        success: true,
                        tool_count: this.discoveredTools.length,
                    });
                })(),
                new Promise((_, reject) => {
                    timeoutId = setTimeout(() => reject(new Error(`Timed out connecting to chrome-devtools-mcp (${connectTimeoutMs}ms)`)), connectTimeoutMs);
                }),
            ]);
        }
        catch (error) {
            await this.close();
            const rawErrorMessage = error instanceof Error ? error.message : String(error);
            const errorType = BrowserManager.classifyConnectionError(rawErrorMessage);
            logBrowserAgentConnection(this.config, Date.now() - connectStartMs, {
                session_mode: sessionMode,
                headless: effectiveHeadless,
                success: false,
                error_type: errorType,
            });
            // Provide error-specific, session-mode-aware remediation
            throw this.createConnectionError(rawErrorMessage, sessionMode);
        }
        finally {
            if (timeoutId !== undefined) {
                clearTimeout(timeoutId);
            }
        }
    }
    /**
     * Classifies a connection error message into a known error type.
     * Shared between connectMcp error recording and createConnectionError
     * to ensure consistent error categorization across the browser agent.
     */
    static classifyConnectionError(message) {
        const lowerMessage = message.toLowerCase();
        if (lowerMessage.includes('already running')) {
            return 'profile_locked';
        }
        else if (lowerMessage.includes('timed out')) {
            return 'timeout';
        }
        else if (lowerMessage.includes('connection refused')) {
            return 'connection_refused';
        }
        return 'unknown';
    }
    /**
     * Creates an Error with context-specific remediation based on the actual
     * error message and the current sessionMode.
     */
    createConnectionError(message, sessionMode) {
        const errorType = BrowserManager.classifyConnectionError(message);
        // "already running for the current profile" — persistent mode profile lock
        if (errorType === 'profile_locked') {
            if (sessionMode === 'persistent' || sessionMode === 'isolated') {
                return new Error(`Could not connect to Chrome: ${message}\n\n` +
                    `The Chrome profile is locked by another running instance.\n` +
                    `To fix this:\n` +
                    `  1. Close all Chrome windows using this profile, OR\n` +
                    `  2. Set sessionMode to "isolated" in settings.json to use a temporary profile, OR\n` +
                    `  3. Set profilePath in settings.json to use a different profile directory`);
            }
            // existing mode — shouldn't normally hit this, but handle gracefully
            return new Error(`Could not connect to Chrome: ${message}\n\n` +
                `The Chrome profile is locked.\n` +
                `Close other Chrome instances and try again.`);
        }
        // Timeout errors
        if (errorType === 'timeout') {
            if (sessionMode === 'existing') {
                return new Error(`Timed out connecting to Chrome: ${message}\n\n` +
                    `To use sessionMode "existing", you must:\n` +
                    `  1. Open Chrome (version 144+)\n` +
                    `  2. Navigate to chrome://inspect/#remote-debugging\n` +
                    `  3. Enable remote debugging\n\n` +
                    `Alternatively, set sessionMode to "persistent" (default) in settings.json to launch a dedicated browser.`);
            }
            return new Error(`Timed out connecting to Chrome: ${message}\n\n` +
                `Possible causes:\n` +
                `  1. Chrome is not installed or not in PATH\n` +
                `  2. Chrome failed to start (try setting headless: true in settings.json)`);
        }
        // Generic "existing" mode failures (connection refused, etc.)
        if (sessionMode === 'existing') {
            return new Error(`Failed to connect to existing Chrome instance: ${message}\n\n` +
                `To use sessionMode "existing", you must:\n` +
                `  1. Open Chrome (version 144+)\n` +
                `  2. Navigate to chrome://inspect/#remote-debugging\n` +
                `  3. Enable remote debugging\n\n` +
                `Alternatively, set sessionMode to "persistent" (default) in settings.json to launch a dedicated browser.`);
        }
        // Generic fallback — include sessionMode for debugging context
        return new Error(`Failed to connect to Chrome (sessionMode: ${sessionMode}): ${message}`);
    }
    /**
     * Discovers tools from the connected MCP server.
     */
    async discoverTools() {
        if (!this.rawMcpClient) {
            throw new Error('MCP client not connected');
        }
        const response = await this.rawMcpClient.listTools();
        this.discoveredTools = response.tools;
        debugLogger.log(`Discovered ${this.discoveredTools.length} tools from chrome-devtools-mcp: ` +
            this.discoveredTools.map((t) => t.name).join(', '));
    }
    /**
     * Check navigation restrictions based on tools and the args sent
     * along with them.
     *
     * @returns error message if failed, undefined if passed.
     */
    checkNavigationRestrictions(toolName, args) {
        const pageNavigationTools = ['navigate_page', 'new_page'];
        if (!pageNavigationTools.includes(toolName)) {
            return undefined;
        }
        const allowedDomains = this.config.getBrowserAgentConfig().customConfig.allowedDomains;
        if (!allowedDomains || allowedDomains.length === 0) {
            return undefined;
        }
        const url = args['url'];
        if (!url) {
            return undefined;
        }
        if (typeof url !== 'string') {
            return `Invalid URL: URL must be a string.`;
        }
        try {
            const parsedUrl = new URL(url);
            const urlHostname = parsedUrl.hostname;
            if (!this.isDomainAllowed(urlHostname, allowedDomains)) {
                return 'Domain not allowed: The requested domain is not in the allowed list.';
            }
            // Check query parameters for embedded URLs that could bypass domain
            // restrictions via proxy services (e.g. translate.google.com/translate?u=BLOCKED).
            const paramsToCheck = [
                ...parsedUrl.searchParams.values(),
                // Also check fragments which might contain query-like params
                ...new URLSearchParams(parsedUrl.hash.replace(/^#/, '')).values(),
            ];
            for (const paramValue of paramsToCheck) {
                try {
                    const embeddedUrl = new URL(paramValue);
                    if (embeddedUrl.protocol === 'http:' ||
                        embeddedUrl.protocol === 'https:') {
                        const embeddedHostname = embeddedUrl.hostname.replace(/\.$/, '');
                        if (!this.isDomainAllowed(embeddedHostname, allowedDomains)) {
                            return 'Domain not allowed: Embedded URL targets a disallowed domain.';
                        }
                    }
                }
                catch {
                    // Not a valid URL, skip.
                }
            }
            return undefined;
        }
        catch {
            return `Invalid URL: Malformed URL string.`;
        }
    }
    /**
     * Checks whether a hostname matches any pattern in the allowed domains list.
     */
    isDomainAllowed(hostname, allowedDomains) {
        const normalized = hostname.replace(/\.$/, '');
        for (const domainPattern of allowedDomains) {
            if (domainPattern.startsWith('*.')) {
                const baseDomain = domainPattern.substring(2);
                if (normalized === baseDomain ||
                    normalized.endsWith(`.${baseDomain}`)) {
                    return true;
                }
            }
            else {
                if (normalized === domainPattern) {
                    return true;
                }
            }
        }
        // If none matched, then deny
        return false;
    }
}
//# sourceMappingURL=browserManager.js.map