XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
UTF-8
"""
g4f — CDP relay: expose browser-extension agents as a CDP endpoint.

The g4f API server can use browsers provided by the g4f browser extension
(projects/browser-extension). The extension connects OUT to this relay over
a WebSocket and executes CDP commands locally via chrome.debugger on
dedicated automation tabs. This module presents the classic CDP HTTP surface
(/json, /json/new, /json/close) plus per-target WebSocket pass-through, so
g4f's own CDPSession (g4f/requests/cdp.py) can connect to it unchanged.

Wire protocol between relay and extension agent (JSON):
    -> {type:"cdp", id, tabId, method, params}   execute CDP command
    <- {type:"cdp", id, result?, error?}         command result
    -> {type:"new_tab", url?}  <- {type:"tab", tabId, url, title}
    -> {type:"close_tab", tabId} <- {type:"closed", tabId}
    -> {type:"tabs"}           <- {type:"tabs", tabs:[{tabId,title,url}]}

Enable from the extension's options page ("Browser as CDP provider") or by
setting G4F_BROWSER_MODE=extension before starting the server.
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from typing import Dict, List, Optional

from fastapi import WebSocket, WebSocketDisconnect

try:  # FastAPI / Starlette
    from starlette.websockets import WebSocketState
except ImportError:  # pragma: no cover
    WebSocketState = None  # type: ignore

debug = logging.getLogger("g4f.cdp_relay")

AGENT_PATH = "/v1/cdp/agent"  # extension agents connect here


class AgentConnection:
    """One connected extension agent (one browser)."""

    def __init__(self, ws: WebSocket, client_id: str):
        self.ws = ws
        self.client_id = client_id
        self.pending: Dict[int, asyncio.Future] = {}
        self._id = 0
        self.tabs: List[dict] = []
        self.last_seen: float = time.time()

    async def send(self, obj: dict) -> None:
        await self.ws.send_text(json.dumps(obj))

    async def roundtrip(self, obj: dict, timeout: float = 30.0) -> dict:
        """Send a request expecting a correlated response."""
        self._id += 1
        msg_id = self._id
        fut: asyncio.Future = asyncio.get_event_loop().create_future()
        self.pending[msg_id] = fut
        try:
            await self.send({**obj, "id": msg_id})
            return await asyncio.wait_for(fut, timeout)
        finally:
            self.pending.pop(msg_id, None)

    def resolve(self, msg: dict) -> None:
        """Resolve a pending roundtrip by response id."""
        fut = self.pending.pop(msg.get("id"), None)
        if fut and not fut.done():
            if "error" in msg and msg["error"] is not None:
                fut.set_exception(RuntimeError(str(msg["error"])))
            else:
                fut.set_result(msg)


class CdpRelay:
    """
    Registry of connected agents + the CDP-over-HTTP facade.

    Presents the subset of the Chrome DevTools HTTP API that
    g4f.requests.cdp.CDPSession uses:
        GET  /json            -> list targets
        PUT  /json/new        -> create target (returns webSocketDebuggerUrl)
        GET  /json/close/{id} -> close target
    plus WebSocket pass-through at /v1/cdp/ws/{target_id}.
    """

    def __init__(self):
        self.agents: Dict[str, AgentConnection] = {}
        # target_id -> (client_id, tab_id)
        self.targets: Dict[str, tuple] = {}
        self._lock = asyncio.Lock()
        # Zombie reaper: MV3 service workers die after ~30s idle, which can
        # leave sockets that no longer respond. Track liveness via pings.
        self._reaper_task: Optional[asyncio.Task] = None

    def _ensure_reaper(self) -> None:
        if self._reaper_task is None or self._reaper_task.done():
            self._reaper_task = asyncio.create_task(self._reaper())

    async def _reaper(self) -> None:
        """Drop agents that stop answering pings (dead service workers)."""
        while self.agents:
            await asyncio.sleep(10)
            for agent in list(self.agents.values()):
                if time.time() - agent.last_seen > 45:
                    debug.warning(
                        f"CDP relay: reaping zombie agent '{agent.client_id}'"
                    )
                    try:
                        await agent.ws.close()
                    except Exception:
                        pass
                    await self.unregister(agent)

    # ---------------- agent management ----------------

    async def register(self, ws: WebSocket, client_id: str) -> AgentConnection:
        agent = AgentConnection(ws, client_id)
        async with self._lock:
            # One agent per extension id: newest wins.
            old = self.agents.get(client_id)
            self.agents[client_id] = agent
        self._ensure_reaper()
        if old and old is not agent:
            try:
                await old.ws.close()
            except Exception:
                pass
        # An agent is connected: route CDPSession traffic through the relay
        # instead of launching a local Chrome instance.
        self._set_extension_mode(True)
        debug.info(f"CDP relay: agent '{client_id}' connected")
        return agent

    async def unregister(self, agent: AgentConnection) -> None:
        async with self._lock:
            if self.agents.get(agent.client_id) is agent:
                del self.agents[agent.client_id]
            dead = [
                tid for tid, (cid, _) in self.targets.items()
                if cid == agent.client_id
            ]
            for tid in dead:
                del self.targets[tid]
        if not self.agents:
            # Last agent gone: fall back to local browser handling.
            self._set_extension_mode(False)
        debug.info(f"CDP relay: agent '{agent.client_id}' disconnected")

    @staticmethod
    def _set_extension_mode(enabled: bool) -> None:
        """Enable/disable extension routing in g4f's CDP core."""
        try:
            from ..cookies import BrowserConfig

            current = getattr(BrowserConfig, "browser_mode", None)
            if enabled and current != "extension":
                BrowserConfig.browser_mode = "extension"
                debug.info("CDP relay: extension mode enabled (agent registered)")
            elif not enabled and current == "extension":
                BrowserConfig.browser_mode = None
                debug.info("CDP relay: extension mode disabled (no agents)")
        except Exception as e:
            debug.warning(f"CDP relay: failed to set browser_mode: {e}")

    def get_agent(self, client_id: Optional[str] = None) -> AgentConnection:
        if client_id and client_id in self.agents:
            return self.agents[client_id]
        if self.agents:
            return next(iter(self.agents.values()))
        raise RuntimeError(
            "No browser extension agent connected. "
            "Enable 'Browser as CDP provider' in the g4f extension."
        )

    # ---------------- CDP facade (used by routes below) ----------------

    async def list_targets(self) -> List[dict]:
        targets = []
        for client_id, agent in list(self.agents.items()):
            try:
                res = await agent.roundtrip({"type": "tabs"}, timeout=5)
                agent.tabs = res.get("tabs", [])
            except Exception as e:
                debug.warning(f"CDP relay: tabs query failed for {client_id}: {e}")
                agent.tabs = []
            for tab in agent.tabs:
                targets.append(
                    {
                        "id": f"{client_id}:{tab['tabId']}",
                        "type": "page",
                        "title": tab.get("title", ""),
                        "url": tab.get("url", ""),
                        "attached": False,
                    }
                )
        return targets

    async def new_target(self, url: str = "about:blank") -> dict:
        agent = self.get_agent()
        res = await agent.roundtrip({"type": "new_tab", "url": url}, timeout=15)
        tab_id = res.get("tabId")
        target_id = f"{agent.client_id}:{tab_id}"
        async with self._lock:
            self.targets[target_id] = (agent.client_id, tab_id)
        return {
            "id": target_id,
            "type": "page",
            "title": res.get("title", ""),
            "url": res.get("url", url),
            "webSocketDebuggerUrl": f"ws://__relay__/{target_id}",
        }

    async def close_target(self, target_id: str) -> bool:
        client_id, tab_id = self.targets.get(target_id, (None, None))
        if client_id is None:
            return False
        agent = self.agents.get(client_id)
        if not agent:
            return False
        try:
            await agent.roundtrip({"type": "close_tab", "tabId": tab_id}, timeout=5)
        except Exception:
            pass
        async with self._lock:
            self.targets.pop(target_id, None)
        return True

    def split_target(self, target_id: str):
        """target_id 'client:tab' -> (client_id, tab_id:int)."""
        client_id, _, tab = target_id.rpartition(":")
        if not client_id or not tab.isdigit():
            raise RuntimeError(f"Invalid target id: {target_id}")
        return client_id, int(tab)


# Single relay instance for the app.
relay = CdpRelay()


async def agent_endpoint(ws: WebSocket) -> None:
    """WebSocket endpoint: extension agents connect here."""
    client_id = ws.query_params.get("client", "extension")
    await ws.accept()
    agent = await relay.register(ws, client_id)
    try:
        while True:
            raw = await ws.receive_text()
            agent.last_seen = time.time()
            try:
                msg = json.loads(raw)
            except ValueError:
                continue
            mtype = msg.get("type")
            if mtype == "ping":
                # Agent keepalive — reply so it knows the link is healthy.
                agent.last_seen = time.time()
                try:
                    await agent.send({"type": "pong", "id": msg.get("id")})
                except Exception:
                    pass
                continue
            if mtype == "cdp" and "id" in msg:
                agent.resolve(msg)
            elif mtype in ("tab", "tabs", "closed", "pong", "attached", "detached"):
                # responses to new_tab / tabs / close_tab / ping roundtrips
                agent.resolve(msg)
            elif mtype == "error":
                agent.resolve(msg)
            # "attached"/"detached"/"pong" are informational for now
    except WebSocketDisconnect:
        pass
    except Exception as e:  # pragma: no cover
        debug.warning(f"CDP relay: agent error: {e}")
    finally:
        await relay.unregister(agent)


async def cdp_ws_endpoint(ws: WebSocket, target_id: str) -> None:
    """
    WebSocket endpoint: CDPSession connects here per target.
    Passes CDP commands through to the extension agent.
    """
    await ws.accept()
    try:
        client_id, tab_id = relay.split_target(target_id)
        agent = relay.get_agent(client_id)
    except RuntimeError as e:
        await ws.close(code=4404, reason=str(e))
        return

    async def reader():
        """g4f -> extension"""
        try:
            while True:
                raw = await ws.receive_text()
                try:
                    cmd = json.loads(raw)
                except ValueError:
                    continue
                req_id = cmd.get("id")
                try:
                    result = await agent.roundtrip(
                        {"type": "cdp", "tabId": tab_id,
                         "method": cmd.get("method"),
                         "params": cmd.get("params", {})},
                        timeout=60,
                    )
                    await ws.send_text(json.dumps(
                        {"id": req_id, "result": result.get("result", {})}
                    ))
                except Exception as e:
                    await ws.send_text(json.dumps(
                        {"id": req_id, "error": str(e)}
                    ))
        except WebSocketDisconnect:
            pass
        except Exception:
            pass

    async def writer():
        """extension events -> g4f (event fan-out is not needed by CDPSession,
        which polls; keep the loop alive and ignore)."""
        try:
            while True:
                await asyncio.sleep(1)
        except Exception:
            pass

    try:
        await asyncio.gather(reader(), writer())
    except Exception:
        pass


def register_cdp_relay(app) -> None:
    """Attach relay routes to the FastAPI app."""

    @app.websocket(AGENT_PATH)
    async def _agent(ws: WebSocket) -> None:
        await agent_endpoint(ws)

    @app.get("/json")
    async def _json_list() -> List[dict]:
        return await relay.list_targets()

    @app.get("/json/list")
    async def _json_list2() -> List[dict]:
        return await relay.list_targets()

    @app.put("/json/new")
    async def _json_new(url: str = "about:blank") -> dict:
        return await relay.new_target(url)

    @app.get("/json/new")
    async def _json_new_get(url: str = "about:blank") -> dict:
        return await relay.new_target(url)

    @app.get("/json/close/{target_id}")
    async def _json_close(target_id: str) -> dict:
        ok = await relay.close_target(target_id)
        return {"ok": ok}

    @app.websocket("/v1/cdp/ws/{target_id}")
    async def _cdp(ws: WebSocket, target_id: str) -> None:
        await cdp_ws_endpoint(ws, target_id)