browser-agent-server 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_agent/__init__.py +128 -0
- browser_agent/chrome.py +591 -0
- browser_agent/cli.py +414 -0
- browser_agent/data/browser-agent.service +26 -0
- browser_agent/server.py +250 -0
- browser_agent/vision_agent.py +165 -0
- browser_agent/x11_input.py +273 -0
- browser_agent_server-1.0.0.dist-info/METADATA +98 -0
- browser_agent_server-1.0.0.dist-info/RECORD +12 -0
- browser_agent_server-1.0.0.dist-info/WHEEL +4 -0
- browser_agent_server-1.0.0.dist-info/entry_points.txt +2 -0
- browser_agent_server-1.0.0.dist-info/licenses/LICENSE +21 -0
browser_agent/server.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Local HTTP daemon (127.0.0.1:8765) for system-wide Browser Agent access.
|
|
2
|
+
|
|
3
|
+
The daemon owns a single real-Chrome session (``BrowserCoordinator``),
|
|
4
|
+
guards it with a global mutex and hibernates Chrome + Xvfb after an idle
|
|
5
|
+
timeout so a small VPS can keep the listener resident at ~12 MB RAM.
|
|
6
|
+
|
|
7
|
+
Public HTTP contract — API version 1 (see docs/api.md):
|
|
8
|
+
GET /status (aliases: /, /health)
|
|
9
|
+
POST /fetch
|
|
10
|
+
POST /action
|
|
11
|
+
POST /agent
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from importlib.metadata import PackageNotFoundError
|
|
23
|
+
from importlib.metadata import version as _pkg_version
|
|
24
|
+
|
|
25
|
+
from .chrome import RealChromeSession, is_warp_proxy_ready
|
|
26
|
+
from .vision_agent import run_vision_agent
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("browser_agent.server")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = _pkg_version("browser-agent-server")
|
|
32
|
+
except PackageNotFoundError: # running from a source tree without install
|
|
33
|
+
__version__ = "0.0.0+local"
|
|
34
|
+
|
|
35
|
+
#: Version of the public HTTP contract exposed by this daemon.
|
|
36
|
+
API_VERSION = 1
|
|
37
|
+
|
|
38
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
39
|
+
DEFAULT_PORT = int(os.environ.get("BROWSER_AGENT_PORT", "8765"))
|
|
40
|
+
DEFAULT_IDLE_TIMEOUT = int(os.environ.get("BROWSER_AGENT_IDLE_SEC", "60"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BrowserCoordinator:
|
|
44
|
+
"""Thread-safe single-tab Chrome + X11 manager with automatic idle hibernation."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, idle_timeout_sec: int = DEFAULT_IDLE_TIMEOUT):
|
|
47
|
+
self.lock = threading.RLock()
|
|
48
|
+
self.session = RealChromeSession()
|
|
49
|
+
self.idle_timeout_sec = max(15, int(idle_timeout_sec))
|
|
50
|
+
self._stop_reaper = threading.Event()
|
|
51
|
+
self._reaper = threading.Thread(target=self._reap_loop, daemon=True)
|
|
52
|
+
self._reaper.start()
|
|
53
|
+
|
|
54
|
+
def _reap_loop(self) -> None:
|
|
55
|
+
while not self._stop_reaper.wait(5.0):
|
|
56
|
+
if self.lock.acquire(blocking=False):
|
|
57
|
+
try:
|
|
58
|
+
if (
|
|
59
|
+
(self.session.is_running() or self.session.x11.is_running())
|
|
60
|
+
and time.time() - self.session.last_used > self.idle_timeout_sec
|
|
61
|
+
):
|
|
62
|
+
log.info(
|
|
63
|
+
"Idle timeout (%ds) reached — hibernating Chrome & Xvfb to free RAM",
|
|
64
|
+
self.idle_timeout_sec,
|
|
65
|
+
)
|
|
66
|
+
self.session.stop()
|
|
67
|
+
finally:
|
|
68
|
+
self.lock.release()
|
|
69
|
+
|
|
70
|
+
def status(self) -> dict:
|
|
71
|
+
return {
|
|
72
|
+
"ok": True,
|
|
73
|
+
"api_version": API_VERSION,
|
|
74
|
+
"version": __version__,
|
|
75
|
+
"chrome_running": self.session.is_running(),
|
|
76
|
+
"x11_running": self.session.x11.is_running(),
|
|
77
|
+
"display": self.session.x11.display,
|
|
78
|
+
"resolution": f"{self.session.x11.width}x{self.session.x11.height}",
|
|
79
|
+
"mouse_pos": list(self.session.x11.mouse_pos),
|
|
80
|
+
"warp_proxy_ready": is_warp_proxy_ready(),
|
|
81
|
+
"idle_seconds": round(time.time() - self.session.last_used, 1),
|
|
82
|
+
"idle_timeout_sec": self.idle_timeout_sec,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def fetch(self, payload: dict) -> dict:
|
|
86
|
+
with self.lock:
|
|
87
|
+
url = str(payload.get("url") or "").strip()
|
|
88
|
+
if not url:
|
|
89
|
+
raise ValueError("Missing 'url' parameter")
|
|
90
|
+
return self.session.fetch_page(
|
|
91
|
+
url=url,
|
|
92
|
+
wait_sec=float(payload.get("wait_sec", 3.5)),
|
|
93
|
+
solve_cloudflare=bool(payload.get("solve_cloudflare", True)),
|
|
94
|
+
use_proxy=payload.get("use_proxy"),
|
|
95
|
+
include_screenshot=bool(payload.get("include_screenshot", False)),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def action(self, payload: dict) -> dict:
|
|
99
|
+
with self.lock:
|
|
100
|
+
act = str(payload.get("action") or "").strip().lower()
|
|
101
|
+
self.session.last_used = time.time()
|
|
102
|
+
|
|
103
|
+
if act == "stop":
|
|
104
|
+
self.session.stop()
|
|
105
|
+
return {"ok": True, "stopped": True}
|
|
106
|
+
if act == "vnc_start":
|
|
107
|
+
ok = self.session.x11.start_vnc(int(payload.get("port", 5900)))
|
|
108
|
+
return {"ok": ok, "vnc_port": int(payload.get("port", 5900))}
|
|
109
|
+
if act == "vnc_stop":
|
|
110
|
+
self.session.x11.stop_vnc()
|
|
111
|
+
return {"ok": True}
|
|
112
|
+
|
|
113
|
+
self.session.start(use_proxy=payload.get("use_proxy"))
|
|
114
|
+
|
|
115
|
+
if act == "goto":
|
|
116
|
+
url = str(payload.get("url") or "").strip()
|
|
117
|
+
cdp = self.session._connect_page_cdp()
|
|
118
|
+
try:
|
|
119
|
+
cdp.call("Page.navigate", {"url": url})
|
|
120
|
+
finally:
|
|
121
|
+
cdp.close()
|
|
122
|
+
time.sleep(float(payload.get("wait_sec", 2.5)))
|
|
123
|
+
if payload.get("solve_cloudflare", True):
|
|
124
|
+
self.session.solve_cloudflare_if_present()
|
|
125
|
+
cur_url, title, _ = self.session.get_html_and_title()
|
|
126
|
+
return {"ok": True, "url": cur_url, "title": title}
|
|
127
|
+
|
|
128
|
+
if act == "move":
|
|
129
|
+
pos = self.session.x11.move_mouse(int(payload["x"]), int(payload["y"]))
|
|
130
|
+
return {"ok": True, "mouse_pos": list(pos)}
|
|
131
|
+
|
|
132
|
+
if act == "click":
|
|
133
|
+
pos = self.session.x11.click(
|
|
134
|
+
x=int(payload["x"]) if payload.get("x") is not None else None,
|
|
135
|
+
y=int(payload["y"]) if payload.get("y") is not None else None,
|
|
136
|
+
button=int(payload.get("button", 1)),
|
|
137
|
+
double=bool(payload.get("double", False)),
|
|
138
|
+
)
|
|
139
|
+
return {"ok": True, "mouse_pos": list(pos)}
|
|
140
|
+
|
|
141
|
+
if act == "type":
|
|
142
|
+
self.session.x11.type_text(str(payload.get("text") or ""), submit=bool(payload.get("submit", False)))
|
|
143
|
+
return {"ok": True}
|
|
144
|
+
|
|
145
|
+
if act == "key":
|
|
146
|
+
self.session.x11.press_key(str(payload.get("key") or "Return"))
|
|
147
|
+
return {"ok": True}
|
|
148
|
+
|
|
149
|
+
if act == "scroll":
|
|
150
|
+
self.session.x11.scroll(
|
|
151
|
+
clicks=int(payload.get("clicks", 4)),
|
|
152
|
+
direction=str(payload.get("direction", "down")),
|
|
153
|
+
x=int(payload["x"]) if payload.get("x") is not None else None,
|
|
154
|
+
y=int(payload["y"]) if payload.get("y") is not None else None,
|
|
155
|
+
)
|
|
156
|
+
return {"ok": True}
|
|
157
|
+
|
|
158
|
+
if act == "screenshot":
|
|
159
|
+
raw = self.session.x11.screenshot_bytes()
|
|
160
|
+
return {"ok": True, "screenshot_b64": base64.b64encode(raw).decode("ascii")}
|
|
161
|
+
|
|
162
|
+
if act == "html":
|
|
163
|
+
cur_url, title, html = self.session.get_html_and_title()
|
|
164
|
+
return {"ok": True, "url": cur_url, "title": title, "html": html}
|
|
165
|
+
|
|
166
|
+
raise ValueError(f"Unknown action: {act!r}")
|
|
167
|
+
|
|
168
|
+
def agent(self, payload: dict) -> dict:
|
|
169
|
+
with self.lock:
|
|
170
|
+
url = str(payload.get("url") or "").strip()
|
|
171
|
+
goal = str(payload.get("goal") or "").strip()
|
|
172
|
+
if not goal:
|
|
173
|
+
raise ValueError("Missing 'goal' parameter")
|
|
174
|
+
return run_vision_agent(
|
|
175
|
+
session=self.session,
|
|
176
|
+
url=url,
|
|
177
|
+
goal=goal,
|
|
178
|
+
max_steps=int(payload.get("max_steps", 8)),
|
|
179
|
+
use_proxy=payload.get("use_proxy"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def shutdown(self) -> None:
|
|
183
|
+
self._stop_reaper.set()
|
|
184
|
+
with self.lock:
|
|
185
|
+
self.session.stop()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def make_handler(coord: BrowserCoordinator):
|
|
189
|
+
class Handler(BaseHTTPRequestHandler):
|
|
190
|
+
def log_message(self, fmt: str, *args) -> None:
|
|
191
|
+
log.info("%s - %s", self.address_string(), fmt % args)
|
|
192
|
+
|
|
193
|
+
def _send_json(self, code: int, data: dict) -> None:
|
|
194
|
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
195
|
+
self.send_response(code)
|
|
196
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
197
|
+
self.send_header("Content-Length", str(len(body)))
|
|
198
|
+
self.end_headers()
|
|
199
|
+
self.wfile.write(body)
|
|
200
|
+
|
|
201
|
+
def _read_json(self) -> dict:
|
|
202
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
203
|
+
if length <= 0:
|
|
204
|
+
return {}
|
|
205
|
+
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
206
|
+
|
|
207
|
+
def do_GET(self) -> None: # noqa: N802
|
|
208
|
+
if self.path.rstrip("/") in ("", "/health", "/status"):
|
|
209
|
+
self._send_json(200, coord.status())
|
|
210
|
+
else:
|
|
211
|
+
self._send_json(404, {"ok": False, "error": "Not found"})
|
|
212
|
+
|
|
213
|
+
def do_POST(self) -> None: # noqa: N802
|
|
214
|
+
try:
|
|
215
|
+
payload = self._read_json()
|
|
216
|
+
path = self.path.rstrip("/")
|
|
217
|
+
if path == "/fetch":
|
|
218
|
+
self._send_json(200, coord.fetch(payload))
|
|
219
|
+
elif path == "/action":
|
|
220
|
+
self._send_json(200, coord.action(payload))
|
|
221
|
+
elif path == "/agent":
|
|
222
|
+
self._send_json(200, coord.agent(payload))
|
|
223
|
+
else:
|
|
224
|
+
self._send_json(404, {"ok": False, "error": f"Unknown endpoint {path}"})
|
|
225
|
+
except Exception as e: # noqa: BLE001
|
|
226
|
+
log.warning("Request %s failed: %s", self.path, e)
|
|
227
|
+
self._send_json(500, {"ok": False, "error": str(e)})
|
|
228
|
+
|
|
229
|
+
return Handler
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> int:
|
|
233
|
+
"""Run the localhost HTTP daemon in the foreground. Returns process exit code."""
|
|
234
|
+
coord = BrowserCoordinator(idle_timeout_sec=idle_timeout)
|
|
235
|
+
srv = ThreadingHTTPServer((host, port), make_handler(coord))
|
|
236
|
+
log.info("Browser Agent daemon listening on http://%s:%d (idle_timeout=%ds)", host, port, idle_timeout)
|
|
237
|
+
try:
|
|
238
|
+
srv.serve_forever()
|
|
239
|
+
except KeyboardInterrupt:
|
|
240
|
+
pass
|
|
241
|
+
finally:
|
|
242
|
+
coord.shutdown()
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
# Compatibility with `python -m browser_agent.server ...`
|
|
248
|
+
from .cli import main
|
|
249
|
+
|
|
250
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Vision-guided Computer Use loop powered by Gemini Flash and hardware-level X11 mouse/keyboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from .chrome import RealChromeSession, _extract_text_from_html
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("browser_agent.vision")
|
|
15
|
+
|
|
16
|
+
VISION_MODELS = (
|
|
17
|
+
"gemini-2.5-flash",
|
|
18
|
+
"gemini-2.5-flash-lite",
|
|
19
|
+
"gemini-3-flash-preview",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SYSTEM_PROMPT = """You are a Linux X11 desktop browser agent controlling a real Chrome window (resolution 1280x720).
|
|
23
|
+
Given the user's goal, current URL, action history, and current 1280x720 screenshot, return ONLY a JSON object with the next action:
|
|
24
|
+
{
|
|
25
|
+
"thought": "Brief reasoning about what is visible on screen and what to do next",
|
|
26
|
+
"action": "click" | "type" | "scroll" | "key" | "wait" | "done",
|
|
27
|
+
"x": 640, // integer X coordinate (0..1280) for click/scroll
|
|
28
|
+
"y": 360, // integer Y coordinate (0..720) for click/scroll
|
|
29
|
+
"text": "...", // text to type when action == "type"
|
|
30
|
+
"submit": true, // press Enter after typing when action == "type"
|
|
31
|
+
"direction": "down", // "down" or "up" when action == "scroll"
|
|
32
|
+
"key": "Return", // X11 key name when action == "key" (e.g. "Return", "Escape", "Tab", "Page_Down")
|
|
33
|
+
"result": "..." // final extracted answer or summary when action == "done"
|
|
34
|
+
}"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _collect_api_keys() -> list[str]:
|
|
38
|
+
keys: list[str] = []
|
|
39
|
+
for var in ("GEMINI_API_KEY", "LLM_API_KEY", "GEMINI_API_KEYS", *[f"GEMINI_API_KEY_{i}" for i in range(1, 11)]):
|
|
40
|
+
val = os.environ.get(var, "").strip()
|
|
41
|
+
if val:
|
|
42
|
+
for part in re.split(r"[\s,;]+", val):
|
|
43
|
+
if part and part not in keys:
|
|
44
|
+
keys.append(part)
|
|
45
|
+
return keys
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _call_gemini_vision(screenshot_png: bytes, prompt_text: str, keys: list[str]) -> dict:
|
|
49
|
+
if not keys:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"No Gemini API key found in environment (set GEMINI_API_KEY or GEMINI_API_KEYS in .env)."
|
|
52
|
+
)
|
|
53
|
+
b64_img = base64.b64encode(screenshot_png).decode("ascii")
|
|
54
|
+
payload = json.dumps({
|
|
55
|
+
"contents": [{
|
|
56
|
+
"parts": [
|
|
57
|
+
{"text": f"{SYSTEM_PROMPT}\n\n{prompt_text}"},
|
|
58
|
+
{"inline_data": {"mime_type": "image/png", "data": b64_img}},
|
|
59
|
+
],
|
|
60
|
+
}],
|
|
61
|
+
"generationConfig": {
|
|
62
|
+
"temperature": 0.2,
|
|
63
|
+
"maxOutputTokens": 1024,
|
|
64
|
+
"responseMimeType": "application/json",
|
|
65
|
+
},
|
|
66
|
+
}).encode("utf-8")
|
|
67
|
+
|
|
68
|
+
last_err = ""
|
|
69
|
+
for key in keys:
|
|
70
|
+
for model in VISION_MODELS:
|
|
71
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
72
|
+
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=25) as resp:
|
|
75
|
+
body = json.loads(resp.read().decode("utf-8"))
|
|
76
|
+
parts = (body.get("candidates") or [])[0]["content"]["parts"]
|
|
77
|
+
raw_text = "".join(p.get("text", "") for p in parts).strip()
|
|
78
|
+
cleaned = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", raw_text)
|
|
79
|
+
obj, _ = json.JSONDecoder().raw_decode(cleaned.lstrip())
|
|
80
|
+
if isinstance(obj, dict):
|
|
81
|
+
return obj
|
|
82
|
+
except Exception as e: # noqa: BLE001
|
|
83
|
+
last_err = f"{model}: {e}"
|
|
84
|
+
continue
|
|
85
|
+
raise RuntimeError(f"All Gemini Vision calls failed ({last_err})")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_vision_agent(
|
|
89
|
+
session: RealChromeSession,
|
|
90
|
+
url: str,
|
|
91
|
+
goal: str,
|
|
92
|
+
max_steps: int = 8,
|
|
93
|
+
use_proxy: bool | None = None,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Navigate to `url` and execute a Vision + X11 mouse/keyboard loop until `goal` is achieved."""
|
|
96
|
+
keys = _collect_api_keys()
|
|
97
|
+
session.start(use_proxy=use_proxy)
|
|
98
|
+
|
|
99
|
+
if url:
|
|
100
|
+
cdp = session._connect_page_cdp()
|
|
101
|
+
try:
|
|
102
|
+
cdp.call("Page.navigate", {"url": url})
|
|
103
|
+
finally:
|
|
104
|
+
cdp.close()
|
|
105
|
+
time.sleep(3.0)
|
|
106
|
+
session.solve_cloudflare_if_present()
|
|
107
|
+
|
|
108
|
+
history: list[dict] = []
|
|
109
|
+
final_result = ""
|
|
110
|
+
|
|
111
|
+
for step in range(1, max(1, min(20, int(max_steps))) + 1):
|
|
112
|
+
session.last_used = time.time()
|
|
113
|
+
cur_url, title, _ = session.get_html_and_title()
|
|
114
|
+
screenshot = session.x11.screenshot_bytes()
|
|
115
|
+
|
|
116
|
+
prompt = (
|
|
117
|
+
f"Goal: {goal}\n"
|
|
118
|
+
f"Current URL: {cur_url}\n"
|
|
119
|
+
f"Page Title: {title}\n"
|
|
120
|
+
f"Step: {step}/{max_steps}\n"
|
|
121
|
+
f"Previous actions: {json.dumps(history[-5:], ensure_ascii=False)}"
|
|
122
|
+
)
|
|
123
|
+
decision = _call_gemini_vision(screenshot, prompt, keys)
|
|
124
|
+
action = str(decision.get("action") or "done").lower()
|
|
125
|
+
thought = str(decision.get("thought") or "")
|
|
126
|
+
log.info("[Vision step %d/%d] action=%s thought=%s", step, max_steps, action, thought)
|
|
127
|
+
history.append({"step": step, **decision})
|
|
128
|
+
|
|
129
|
+
if action == "done":
|
|
130
|
+
final_result = str(decision.get("result") or thought)
|
|
131
|
+
break
|
|
132
|
+
if action == "click":
|
|
133
|
+
x = int(decision.get("x") or session.x11.width // 2)
|
|
134
|
+
y = int(decision.get("y") or session.x11.height // 2)
|
|
135
|
+
session.x11.click(x, y)
|
|
136
|
+
time.sleep(2.0)
|
|
137
|
+
elif action == "type":
|
|
138
|
+
if decision.get("x") is not None and decision.get("y") is not None:
|
|
139
|
+
session.x11.click(int(decision["x"]), int(decision["y"]))
|
|
140
|
+
time.sleep(0.3)
|
|
141
|
+
session.x11.type_text(str(decision.get("text") or ""), submit=bool(decision.get("submit", True)))
|
|
142
|
+
time.sleep(2.0)
|
|
143
|
+
elif action == "scroll":
|
|
144
|
+
session.x11.scroll(
|
|
145
|
+
clicks=int(decision.get("clicks") or 5),
|
|
146
|
+
direction=str(decision.get("direction") or "down"),
|
|
147
|
+
x=int(decision.get("x") or session.x11.width // 2),
|
|
148
|
+
y=int(decision.get("y") or session.x11.height // 2),
|
|
149
|
+
)
|
|
150
|
+
time.sleep(1.2)
|
|
151
|
+
elif action == "key":
|
|
152
|
+
session.x11.press_key(str(decision.get("key") or "Return"))
|
|
153
|
+
time.sleep(1.2)
|
|
154
|
+
elif action == "wait":
|
|
155
|
+
time.sleep(min(5.0, float(decision.get("seconds") or 2.0)))
|
|
156
|
+
|
|
157
|
+
cur_url, title, html = session.get_html_and_title()
|
|
158
|
+
return {
|
|
159
|
+
"ok": True,
|
|
160
|
+
"url": cur_url,
|
|
161
|
+
"title": title,
|
|
162
|
+
"result": final_result or (history[-1].get("thought", "") if history else ""),
|
|
163
|
+
"steps": history,
|
|
164
|
+
"text": _extract_text_from_html(html, cur_url)[:8000],
|
|
165
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Hardware-level X11 display, window manager, screenshot, and human-like mouse/keyboard control."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("browser_agent.x11")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def bezier_curve(
|
|
17
|
+
start: tuple[int, int],
|
|
18
|
+
end: tuple[int, int],
|
|
19
|
+
steps: int | None = None,
|
|
20
|
+
spread: float = 0.18,
|
|
21
|
+
) -> list[tuple[int, int]]:
|
|
22
|
+
"""Generate a human-like cubic Bezier mouse path with ease-in/out pacing and micro-jitter."""
|
|
23
|
+
x0, y0 = start
|
|
24
|
+
x3, y3 = end
|
|
25
|
+
dist = math.hypot(x3 - x0, y3 - y0)
|
|
26
|
+
if dist < 2:
|
|
27
|
+
return [(x3, y3)]
|
|
28
|
+
|
|
29
|
+
if steps is None:
|
|
30
|
+
steps = max(8, min(45, int(dist / 18)))
|
|
31
|
+
|
|
32
|
+
dx, dy = x3 - x0, y3 - y0
|
|
33
|
+
perp_x, perp_y = -dy, dx
|
|
34
|
+
offset1 = random.uniform(-spread, spread)
|
|
35
|
+
offset2 = random.uniform(-spread, spread)
|
|
36
|
+
|
|
37
|
+
x1 = x0 + dx * random.uniform(0.2, 0.35) + perp_x * offset1
|
|
38
|
+
y1 = y0 + dy * random.uniform(0.2, 0.35) + perp_y * offset1
|
|
39
|
+
x2 = x0 + dx * random.uniform(0.65, 0.85) + perp_x * offset2
|
|
40
|
+
y2 = y0 + dy * random.uniform(0.65, 0.85) + perp_y * offset2
|
|
41
|
+
|
|
42
|
+
points: list[tuple[int, int]] = []
|
|
43
|
+
for i in range(1, steps + 1):
|
|
44
|
+
raw_t = i / steps
|
|
45
|
+
# Smoothstep ease-in / ease-out velocity profile
|
|
46
|
+
t = raw_t * raw_t * (3.0 - 2.0 * raw_t)
|
|
47
|
+
u = 1.0 - t
|
|
48
|
+
bx = (u**3) * x0 + 3 * (u**2) * t * x1 + 3 * u * (t**2) * x2 + (t**3) * x3
|
|
49
|
+
by = (u**3) * y0 + 3 * (u**2) * t * y1 + 3 * u * (t**2) * y2 + (t**3) * y3
|
|
50
|
+
if i < steps:
|
|
51
|
+
bx += random.gauss(0, 0.6)
|
|
52
|
+
by += random.gauss(0, 0.6)
|
|
53
|
+
pt = (max(0, int(round(bx))), max(0, int(round(by))))
|
|
54
|
+
if not points or points[-1] != pt:
|
|
55
|
+
points.append(pt)
|
|
56
|
+
|
|
57
|
+
if points[-1] != (x3, y3):
|
|
58
|
+
points.append((x3, y3))
|
|
59
|
+
return points
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class X11Display:
|
|
63
|
+
"""Manages an on-demand Xvfb virtual screen, openbox window manager, and xdotool input."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, display: str = ":99", width: int = 1280, height: int = 720, depth: int = 24):
|
|
66
|
+
self.display = display
|
|
67
|
+
self.width = width
|
|
68
|
+
self.height = height
|
|
69
|
+
self.depth = depth
|
|
70
|
+
self._xvfb_proc: subprocess.Popen | None = None
|
|
71
|
+
self._wm_proc: subprocess.Popen | None = None
|
|
72
|
+
self._vnc_proc: subprocess.Popen | None = None
|
|
73
|
+
self.mouse_pos: tuple[int, int] = (width // 2, height // 2)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def env(self) -> dict[str, str]:
|
|
77
|
+
env = os.environ.copy()
|
|
78
|
+
env["DISPLAY"] = self.display
|
|
79
|
+
return env
|
|
80
|
+
|
|
81
|
+
def is_running(self) -> bool:
|
|
82
|
+
return self._xvfb_proc is not None and self._xvfb_proc.poll() is None
|
|
83
|
+
|
|
84
|
+
def start(self) -> None:
|
|
85
|
+
if self.is_running():
|
|
86
|
+
return
|
|
87
|
+
display_num = self.display.lstrip(":")
|
|
88
|
+
lock_file = Path(f"/tmp/.X{display_num}-lock")
|
|
89
|
+
socket_file = Path(f"/tmp/.X11-unix/X{display_num}")
|
|
90
|
+
if lock_file.exists():
|
|
91
|
+
try:
|
|
92
|
+
lock_file.unlink()
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
if socket_file.exists():
|
|
96
|
+
try:
|
|
97
|
+
socket_file.unlink()
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
self._xvfb_proc = subprocess.Popen(
|
|
102
|
+
[
|
|
103
|
+
"Xvfb",
|
|
104
|
+
self.display,
|
|
105
|
+
"-screen",
|
|
106
|
+
"0",
|
|
107
|
+
f"{self.width}x{self.height}x{self.depth}",
|
|
108
|
+
"-ac",
|
|
109
|
+
"-nolisten",
|
|
110
|
+
"tcp",
|
|
111
|
+
"-dpi",
|
|
112
|
+
"96",
|
|
113
|
+
],
|
|
114
|
+
stdout=subprocess.DEVNULL,
|
|
115
|
+
stderr=subprocess.PIPE,
|
|
116
|
+
)
|
|
117
|
+
time.sleep(0.4)
|
|
118
|
+
if self._xvfb_proc.poll() is not None:
|
|
119
|
+
err = (self._xvfb_proc.stderr.read().decode("utf-8", errors="replace") if self._xvfb_proc.stderr else "").strip()
|
|
120
|
+
raise RuntimeError(f"Xvfb failed to start on {self.display} (rc={self._xvfb_proc.returncode}): {err[:300]}")
|
|
121
|
+
if shutil.which("openbox"):
|
|
122
|
+
self._wm_proc = subprocess.Popen(
|
|
123
|
+
["openbox"],
|
|
124
|
+
env=self.env,
|
|
125
|
+
stdout=subprocess.DEVNULL,
|
|
126
|
+
stderr=subprocess.DEVNULL,
|
|
127
|
+
)
|
|
128
|
+
time.sleep(0.2)
|
|
129
|
+
log.info("X11 display %s started (%dx%d)", self.display, self.width, self.height)
|
|
130
|
+
|
|
131
|
+
def stop(self) -> None:
|
|
132
|
+
self.stop_vnc()
|
|
133
|
+
for proc in (self._wm_proc, self._xvfb_proc):
|
|
134
|
+
if proc and proc.poll() is None:
|
|
135
|
+
proc.terminate()
|
|
136
|
+
try:
|
|
137
|
+
proc.wait(timeout=3)
|
|
138
|
+
except subprocess.TimeoutExpired:
|
|
139
|
+
proc.kill()
|
|
140
|
+
self._wm_proc = None
|
|
141
|
+
self._xvfb_proc = None
|
|
142
|
+
|
|
143
|
+
def start_vnc(self, port: int = 5900) -> bool:
|
|
144
|
+
"""Start localhost-only x11vnc for live debugging over SSH tunnel."""
|
|
145
|
+
self.start()
|
|
146
|
+
if self._vnc_proc and self._vnc_proc.poll() is None:
|
|
147
|
+
return True
|
|
148
|
+
if not shutil.which("x11vnc"):
|
|
149
|
+
return False
|
|
150
|
+
self._vnc_proc = subprocess.Popen(
|
|
151
|
+
[
|
|
152
|
+
"x11vnc",
|
|
153
|
+
"-display",
|
|
154
|
+
self.display,
|
|
155
|
+
"-localhost",
|
|
156
|
+
"-nopw",
|
|
157
|
+
"-forever",
|
|
158
|
+
"-shared",
|
|
159
|
+
"-noxdamage",
|
|
160
|
+
"-rfbport",
|
|
161
|
+
str(port),
|
|
162
|
+
"-quiet",
|
|
163
|
+
],
|
|
164
|
+
stdout=subprocess.DEVNULL,
|
|
165
|
+
stderr=subprocess.DEVNULL,
|
|
166
|
+
)
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def stop_vnc(self) -> None:
|
|
170
|
+
if self._vnc_proc and self._vnc_proc.poll() is None:
|
|
171
|
+
self._vnc_proc.terminate()
|
|
172
|
+
try:
|
|
173
|
+
self._vnc_proc.wait(timeout=2)
|
|
174
|
+
except subprocess.TimeoutExpired:
|
|
175
|
+
self._vnc_proc.kill()
|
|
176
|
+
self._vnc_proc = None
|
|
177
|
+
|
|
178
|
+
def _xdotool(self, *args: str, timeout: float = 10.0) -> str:
|
|
179
|
+
self.start()
|
|
180
|
+
res = subprocess.run(
|
|
181
|
+
["xdotool", *args],
|
|
182
|
+
env=self.env,
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
timeout=timeout,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
return res.stdout.strip()
|
|
189
|
+
|
|
190
|
+
def move_mouse(self, x: int, y: int, duration: float | None = None) -> tuple[int, int]:
|
|
191
|
+
"""Move the real X11 cursor along a human-like Bezier trajectory to (x, y)."""
|
|
192
|
+
x = max(1, min(self.width - 2, int(x)))
|
|
193
|
+
y = max(1, min(self.height - 2, int(y)))
|
|
194
|
+
dist = math.hypot(x - self.mouse_pos[0], y - self.mouse_pos[1])
|
|
195
|
+
if dist < 2:
|
|
196
|
+
self._xdotool("mousemove", str(x), str(y))
|
|
197
|
+
self.mouse_pos = (x, y)
|
|
198
|
+
return self.mouse_pos
|
|
199
|
+
|
|
200
|
+
path = bezier_curve(self.mouse_pos, (x, y))
|
|
201
|
+
if duration is None:
|
|
202
|
+
duration = max(0.08, min(0.45, dist / 1600.0 + random.uniform(0.03, 0.10)))
|
|
203
|
+
|
|
204
|
+
step_delay = duration / max(1, len(path))
|
|
205
|
+
for px, py in path:
|
|
206
|
+
self._xdotool("mousemove", str(px), str(py))
|
|
207
|
+
time.sleep(max(0.003, step_delay * random.uniform(0.75, 1.25)))
|
|
208
|
+
|
|
209
|
+
self.mouse_pos = (x, y)
|
|
210
|
+
return self.mouse_pos
|
|
211
|
+
|
|
212
|
+
def click(
|
|
213
|
+
self,
|
|
214
|
+
x: int | None = None,
|
|
215
|
+
y: int | None = None,
|
|
216
|
+
button: int = 1,
|
|
217
|
+
double: bool = False,
|
|
218
|
+
jitter: int = 2,
|
|
219
|
+
) -> tuple[int, int]:
|
|
220
|
+
"""Move humanly to (x, y) if given and perform a hardware-level X11 mouse click."""
|
|
221
|
+
if x is not None and y is not None:
|
|
222
|
+
tx = int(x) + (random.randint(-jitter, jitter) if jitter > 0 else 0)
|
|
223
|
+
ty = int(y) + (random.randint(-jitter, jitter) if jitter > 0 else 0)
|
|
224
|
+
self.move_mouse(tx, ty)
|
|
225
|
+
|
|
226
|
+
time.sleep(random.uniform(0.03, 0.08))
|
|
227
|
+
clicks = 2 if double else 1
|
|
228
|
+
for i in range(clicks):
|
|
229
|
+
self._xdotool("mousedown", str(button))
|
|
230
|
+
time.sleep(random.uniform(0.055, 0.125))
|
|
231
|
+
self._xdotool("mouseup", str(button))
|
|
232
|
+
if i + 1 < clicks:
|
|
233
|
+
time.sleep(random.uniform(0.06, 0.11))
|
|
234
|
+
return self.mouse_pos
|
|
235
|
+
|
|
236
|
+
def scroll(self, clicks: int = 4, direction: str = "down", x: int | None = None, y: int | None = None) -> None:
|
|
237
|
+
if x is not None and y is not None:
|
|
238
|
+
self.move_mouse(x, y)
|
|
239
|
+
btn = "5" if direction.lower() in ("down", "d") else "4"
|
|
240
|
+
for _ in range(max(1, abs(int(clicks)))):
|
|
241
|
+
self._xdotool("click", btn)
|
|
242
|
+
time.sleep(random.uniform(0.04, 0.09))
|
|
243
|
+
|
|
244
|
+
def type_text(self, text: str, submit: bool = False) -> None:
|
|
245
|
+
delay_ms = random.randint(35, 75)
|
|
246
|
+
self._xdotool("type", "--clearmodifiers", "--delay", str(delay_ms), "--", text, timeout=30.0)
|
|
247
|
+
if submit:
|
|
248
|
+
time.sleep(random.uniform(0.08, 0.18))
|
|
249
|
+
self.press_key("Return")
|
|
250
|
+
|
|
251
|
+
def press_key(self, key: str) -> None:
|
|
252
|
+
self._xdotool("key", "--clearmodifiers", key)
|
|
253
|
+
|
|
254
|
+
def screenshot_bytes(self) -> bytes:
|
|
255
|
+
"""Capture X11 screen via scrot into /tmp and immediately delete the temporary file."""
|
|
256
|
+
self.start()
|
|
257
|
+
tmp_path = Path(f"/tmp/ba_screen_{os.getpid()}_{int(time.time() * 1000)}.png")
|
|
258
|
+
try:
|
|
259
|
+
subprocess.run(
|
|
260
|
+
["scrot", "--overwrite", str(tmp_path)],
|
|
261
|
+
env=self.env,
|
|
262
|
+
stdout=subprocess.DEVNULL,
|
|
263
|
+
stderr=subprocess.DEVNULL,
|
|
264
|
+
timeout=10,
|
|
265
|
+
check=True,
|
|
266
|
+
)
|
|
267
|
+
return tmp_path.read_bytes()
|
|
268
|
+
finally:
|
|
269
|
+
if tmp_path.exists():
|
|
270
|
+
try:
|
|
271
|
+
tmp_path.unlink()
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|