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/cli.py ADDED
@@ -0,0 +1,414 @@
1
+ """Command line interface: ``browser-agent …``.
2
+
3
+ Subcommands:
4
+ serve Run the localhost HTTP daemon (what systemd starts)
5
+ status Show daemon & X11 status
6
+ stop Hibernate Chrome and Xvfb immediately
7
+ fetch Open a URL in real Chrome and print text/html/JSON
8
+ click / move / type Hardware-level X11 input
9
+ agent Vision-guided computer-use loop
10
+ vnc Start/stop localhost VNC for live viewing over SSH
11
+ setup Provision a Linux host (apt packages, Google Chrome, systemd unit)
12
+ doctor Self-check this host and the running daemon
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import base64
18
+ import getpass
19
+ import json
20
+ import logging
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import urllib.error
26
+ import urllib.request
27
+ from importlib import resources
28
+ from pathlib import Path
29
+
30
+ from .chrome import find_chrome_binary, is_warp_proxy_ready
31
+ from .server import API_VERSION, DEFAULT_HOST, DEFAULT_IDLE_TIMEOUT, DEFAULT_PORT, serve
32
+
33
+ log = logging.getLogger("browser_agent.cli")
34
+
35
+ APT_PACKAGES = ("xvfb", "openbox", "xdotool", "scrot", "x11vnc")
36
+ STATE_HOME = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "browser-agent"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # HTTP helpers for client-side subcommands
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _api_request(
44
+ method: str,
45
+ path: str,
46
+ payload: dict | None = None,
47
+ port: int = DEFAULT_PORT,
48
+ timeout: float = 90.0,
49
+ ) -> dict | None:
50
+ url = f"http://{DEFAULT_HOST}:{port}{path}"
51
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
52
+ headers = {"Content-Type": "application/json"} if data is not None else {}
53
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
56
+ return json.loads(resp.read().decode("utf-8"))
57
+ except urllib.error.HTTPError as e:
58
+ try:
59
+ return json.loads(e.read().decode("utf-8"))
60
+ except Exception: # noqa: BLE001
61
+ return {"ok": False, "error": str(e)}
62
+ except OSError:
63
+ return None
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # setup
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def _load_unit_template() -> str:
71
+ return (
72
+ resources.files("browser_agent.data")
73
+ .joinpath("browser-agent.service")
74
+ .read_text(encoding="utf-8")
75
+ )
76
+
77
+
78
+ def _run(cmd: list[str], sudo: list[str], dry_run: bool = False) -> bool:
79
+ full = sudo + cmd
80
+ print(f" $ {' '.join(full)}")
81
+ if dry_run:
82
+ return True
83
+ res = subprocess.run(full, check=False)
84
+ return res.returncode == 0
85
+
86
+
87
+ def _install_chrome_apt(sudo: list[str], dry_run: bool) -> bool:
88
+ """Add Google's apt repo and install google-chrome-stable."""
89
+ steps = [
90
+ "install -m 0755 -d /usr/share/keyrings",
91
+ "sh -c 'curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg'",
92
+ "sh -c 'echo deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main > /etc/apt/sources.list.d/google-chrome.list'",
93
+ "apt-get update",
94
+ "apt-get install -y google-chrome-stable",
95
+ ]
96
+ ok = True
97
+ for s in steps:
98
+ ok = _run(["sh", "-c", s] if s.startswith(("install", "sh")) else s.split(), sudo, dry_run) and ok
99
+ return ok
100
+
101
+
102
+ def cmd_setup(args: argparse.Namespace) -> int:
103
+ dry = args.dry_run
104
+ euid = os.geteuid() if hasattr(os, "geteuid") else None
105
+ sudo: list[str] = [] if euid == 0 else (["sudo"] if shutil.which("sudo") else [])
106
+ if euid != 0 and not sudo:
107
+ print("setup needs root (or sudo). Re-run as root.", file=sys.stderr)
108
+ return 2
109
+
110
+ print("== browser-agent setup ==")
111
+
112
+ if not args.no_apt:
113
+ print(f"-> apt packages: {' '.join(APT_PACKAGES)}")
114
+ _run(["apt-get", "update"], sudo, dry)
115
+ _run(["apt-get", "install", "-y", *APT_PACKAGES], sudo, dry)
116
+ else:
117
+ print("-> skipping apt (--no-apt)")
118
+
119
+ try:
120
+ chrome = find_chrome_binary()
121
+ print(f"-> chrome found: {chrome}")
122
+ except FileNotFoundError:
123
+ chrome = ""
124
+ if args.with_chrome:
125
+ print("-> installing google-chrome-stable from Google's apt repo")
126
+ _install_chrome_apt(sudo, dry)
127
+ else:
128
+ print("-> WARNING: no Chrome/Chromium binary found.", file=sys.stderr)
129
+ print(" Re-run with --with-chrome to install google-chrome-stable automatically,", file=sys.stderr)
130
+ print(" or install it yourself: https://www.google.com/chrome/", file=sys.stderr)
131
+
132
+ if args.no_unit:
133
+ print("-> skipping systemd unit (--no-unit)")
134
+ return 0
135
+
136
+ exec_start = shutil.which("browser-agent") or f"{sys.executable} -m browser_agent.server"
137
+ env_file = args.env_file
138
+ unit = (
139
+ _load_unit_template()
140
+ .replace("{{EXEC_START}}", exec_start)
141
+ .replace("{{ENV_FILE}}", env_file)
142
+ .replace("{{USER}}", args.user or getpass.getuser())
143
+ .replace("{{PORT}}", str(args.port))
144
+ .replace("{{IDLE}}", str(args.idle_timeout))
145
+ .replace("{{MEMORY_HIGH}}", args.memory_high)
146
+ .replace("{{MEMORY_MAX}}", args.memory_max)
147
+ )
148
+ unit_path = Path("/etc/systemd/system/browser-agent.service")
149
+ print(f"-> writing {unit_path}")
150
+ print(" ExecStart:", exec_start)
151
+ if not dry:
152
+ proc = subprocess.run(
153
+ [*sudo, "tee", str(unit_path)], input=unit.encode(), stdout=subprocess.DEVNULL, check=False
154
+ )
155
+ if proc.returncode != 0:
156
+ print("failed to write the unit file", file=sys.stderr)
157
+ return 1
158
+
159
+ env_path = Path(env_file)
160
+ if not env_path.exists() and str(env_path).startswith("/etc/"):
161
+ seed = "# browser-agent environment (chmod 600 — put API keys here)\n# GEMINI_API_KEYS=key1,key2\n"
162
+ subprocess.run([*sudo, "sh", "-c", f"printf '%s' '{seed}' > {env_path} && chmod 600 {env_path}"], check=False)
163
+
164
+ _run(["systemctl", "daemon-reload"], sudo)
165
+ if not args.no_start:
166
+ _run(["systemctl", "enable", "--now", "browser-agent.service"], sudo)
167
+
168
+ print()
169
+ print("Done. Next steps:")
170
+ print(f" 1. Put Gemini key(s) into {env_file} if you plan to use `browser-agent agent`")
171
+ print(" 2. Run: browser-agent doctor")
172
+ print(" 3. Optional live view: browser-agent vnc start (+ ssh -L 5900:127.0.0.1:5900 …)")
173
+ return 0
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # doctor
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _check(name: str, ok: bool, detail: str = "", required: bool = True) -> dict:
181
+ return {"name": name, "ok": bool(ok), "required": required, "detail": detail}
182
+
183
+
184
+ def cmd_doctor(args: argparse.Namespace) -> int:
185
+ checks: list[dict] = []
186
+
187
+ vi = sys.version_info
188
+ checks.append(_check("python>=3.12", vi >= (3, 12), f"running {vi.major}.{vi.minor}.{vi.micro}"))
189
+
190
+ for b in ("Xvfb", "openbox", "xdotool", "scrot"):
191
+ p = shutil.which(b)
192
+ checks.append(_check(f"binary:{b}", bool(p), p or "not found on PATH"))
193
+ p = shutil.which("x11vnc")
194
+ checks.append(_check("binary:x11vnc", bool(p), p or "optional — needed only for `vnc`", required=False))
195
+
196
+ try:
197
+ chrome = find_chrome_binary()
198
+ checks.append(_check("chrome-binary", True, chrome))
199
+ except FileNotFoundError as e:
200
+ checks.append(_check("chrome-binary", False, str(e).split(".")[0]))
201
+
202
+ lock = Path("/tmp/.X99-lock")
203
+ checks.append(_check("display :99 free", not lock.exists(), "stale /tmp/.X99-lock present" if lock.exists() else "no lock", False))
204
+
205
+ try:
206
+ STATE_HOME.mkdir(parents=True, exist_ok=True)
207
+ checks.append(_check("state dir writable", os.access(STATE_HOME, os.W_OK), str(STATE_HOME)))
208
+ except OSError as e:
209
+ checks.append(_check("state dir writable", False, f"{STATE_HOME}: {e}"))
210
+
211
+ total_gb = 0.0
212
+ try:
213
+ for line in Path("/proc/meminfo").read_text().splitlines():
214
+ if line.startswith("MemTotal"):
215
+ total_gb = int(line.split()[1]) / 1024 / 1024
216
+ except OSError:
217
+ pass
218
+ checks.append(_check(
219
+ "ram", total_gb >= 1.4 or total_gb == 0,
220
+ f"{total_gb:.1f} GB total" + (" — tight; keep MemoryMax low and avoid parallel tabs" if 0 < total_gb < 1.4 else ""),
221
+ required=False,
222
+ ))
223
+
224
+ checks.append(_check("warp proxy (127.0.0.1:40000)", is_warp_proxy_ready(), "optional — fallback when the datacenter IP is blocked", False))
225
+
226
+ keys = [v for v in ("GEMINI_API_KEY", "GEMINI_API_KEYS", "LLM_API_KEY") if os.environ.get(v)]
227
+ keys += [f"GEMINI_API_KEY_{i}" for i in range(1, 11) if os.environ.get(f"GEMINI_API_KEY_{i}")]
228
+ checks.append(_check("gemini key(s) for `agent`", bool(keys), ", ".join(keys) if keys else "optional — vision agent needs one", False))
229
+
230
+ unit_active = subprocess.run(["systemctl", "is-active", "browser-agent.service"], capture_output=True, text=True, check=False).stdout.strip() if shutil.which("systemctl") else "n/a"
231
+ checks.append(_check("systemd unit", unit_active == "active", f"is-active: {unit_active}", False))
232
+
233
+ res = _api_request("GET", "/status", port=args.port, timeout=3.0)
234
+ if res is None:
235
+ checks.append(_check("daemon http api", False, f"no answer on 127.0.0.1:{args.port} (start: systemctl start browser-agent)", False))
236
+ else:
237
+ api_ok = res.get("ok") and res.get("api_version") == API_VERSION
238
+ detail = f"api_version={res.get('api_version')} version={res.get('version')} chrome={res.get('chrome_running')} x11={res.get('x11_running')}"
239
+ if res.get("api_version") != API_VERSION:
240
+ detail += f" (this CLI speaks api_version={API_VERSION} — upgrade/downgrade to match)"
241
+ checks.append(_check("daemon http api", api_ok, detail))
242
+
243
+ if args.json:
244
+ failed = [c for c in checks if c["required"] and not c["ok"]]
245
+ print(json.dumps({"ok": not failed, "checks": checks}, indent=2))
246
+ else:
247
+ print("== browser-agent doctor ==")
248
+ for c in checks:
249
+ mark = "✓" if c["ok"] else ("✗" if c["required"] else "!")
250
+ opt = "" if c["required"] else " (optional)"
251
+ print(f" {mark} {c['name']}{opt}" + (f" — {c['detail']}" if c["detail"] else ""))
252
+ failed = [c for c in checks if c["required"] and not c["ok"]]
253
+ print()
254
+ print("OK" if not failed else f"FAIL: {len(failed)} required check(s) failed: " + ", ".join(c["name"] for c in failed))
255
+ return 0 if not any(c["required"] and not c["ok"] for c in checks) else 1
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # argument parsing & dispatch
260
+ # ---------------------------------------------------------------------------
261
+
262
+ def build_parser() -> argparse.ArgumentParser:
263
+ common = argparse.ArgumentParser(add_help=False)
264
+ common.add_argument("--port", type=int, default=DEFAULT_PORT, help="Daemon HTTP port (default: 8765)")
265
+
266
+ parser = argparse.ArgumentParser(prog="browser-agent", description="Real Chrome + X11 Mouse Agent", parents=[common])
267
+ sub = parser.add_subparsers(dest="cmd", required=True)
268
+
269
+ p_serve = sub.add_parser("serve", parents=[common], help="Run the localhost HTTP daemon (127.0.0.1:8765)")
270
+ p_serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
271
+
272
+ sub.add_parser("status", parents=[common], help="Show Browser Agent daemon & X11 status")
273
+ sub.add_parser("stop", parents=[common], help="Hibernate Chrome and Xvfb immediately to free RAM")
274
+
275
+ p_fetch = sub.add_parser("fetch", parents=[common], help="Open URL in real Chrome, solve Cloudflare, extract page")
276
+ p_fetch.add_argument("url")
277
+ p_fetch.add_argument("--wait", type=float, default=3.5, help="Wait seconds after navigation")
278
+ p_fetch.add_argument("--text", action="store_true", help="Print extracted article text only")
279
+ p_fetch.add_argument("--html", action="store_true", help="Print outer HTML only")
280
+ p_fetch.add_argument("--screenshot", type=str, default="", help="Save PNG screenshot to path")
281
+ p_fetch.add_argument("--proxy", action="store_true", help="Route through local WARP SOCKS5 (127.0.0.1:40000)")
282
+
283
+ p_click = sub.add_parser("click", parents=[common], help="Move real X11 mouse along a Bezier curve and click (x, y)")
284
+ p_click.add_argument("x", type=int)
285
+ p_click.add_argument("y", type=int)
286
+ p_click.add_argument("--double", action="store_true")
287
+
288
+ p_move = sub.add_parser("move", parents=[common], help="Move real X11 mouse along a Bezier curve to (x, y)")
289
+ p_move.add_argument("x", type=int)
290
+ p_move.add_argument("y", type=int)
291
+
292
+ p_type = sub.add_parser("type", parents=[common], help="Type text via the X11 keyboard")
293
+ p_type.add_argument("text")
294
+ p_type.add_argument("--submit", action="store_true")
295
+
296
+ p_agent = sub.add_parser("agent", parents=[common], help="Run the vision-guided computer-use agent on a URL")
297
+ p_agent.add_argument("url")
298
+ p_agent.add_argument("goal")
299
+ p_agent.add_argument("--max-steps", type=int, default=8)
300
+ p_agent.add_argument("--proxy", action="store_true")
301
+
302
+ p_vnc = sub.add_parser("vnc", parents=[common], help="Start or stop the localhost VNC server (127.0.0.1:5900)")
303
+ p_vnc.add_argument("mode", choices=["start", "stop"], default="start", nargs="?")
304
+
305
+ p_setup = sub.add_parser("setup", parents=[common], help="Provision a Linux host (apt, Chrome, systemd unit)")
306
+ p_setup.add_argument("--no-apt", action="store_true", help="Do not install apt packages")
307
+ p_setup.add_argument("--with-chrome", action="store_true", help="Install google-chrome-stable from Google's apt repo")
308
+ p_setup.add_argument("--no-unit", action="store_true", help="Do not write the systemd unit")
309
+ p_setup.add_argument("--no-start", action="store_true", help="Write the unit but do not enable/start it")
310
+ p_setup.add_argument("--user", default="", help="User for the systemd unit (default: current user)")
311
+ p_setup.add_argument("--env-file", default="/etc/browser-agent.env", help="EnvironmentFile for the unit")
312
+ p_setup.add_argument("--memory-high", default="450M")
313
+ p_setup.add_argument("--memory-max", default="600M")
314
+ p_setup.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
315
+ p_setup.add_argument("--dry-run", action="store_true", help="Print what would happen, change nothing")
316
+
317
+ p_doctor = sub.add_parser("doctor", parents=[common], help="Self-check this host and the running daemon")
318
+ p_doctor.add_argument("--json", action="store_true", help="Machine-readable output")
319
+
320
+ return parser
321
+
322
+
323
+ def main(argv: list[str] | None = None) -> int:
324
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s", datefmt="%H:%M:%S")
325
+ args = build_parser().parse_args(argv)
326
+
327
+ if args.cmd == "serve":
328
+ return serve(port=args.port, idle_timeout=args.idle_timeout)
329
+
330
+ if args.cmd == "status":
331
+ res = _api_request("GET", "/status", port=args.port, timeout=5.0)
332
+ if res is None:
333
+ print(json.dumps({"ok": False, "daemon_running": False}, indent=2))
334
+ return 1
335
+ print(json.dumps(res, indent=2, ensure_ascii=False))
336
+ return 0
337
+
338
+ if args.cmd == "stop":
339
+ res = _api_request("POST", "/action", {"action": "stop"}, port=args.port, timeout=10.0)
340
+ print(json.dumps(res or {"ok": True, "note": "daemon not running"}, indent=2))
341
+ return 0
342
+
343
+ if args.cmd == "fetch":
344
+ payload = {
345
+ "url": args.url,
346
+ "wait_sec": args.wait,
347
+ "solve_cloudflare": True,
348
+ "use_proxy": True if args.proxy else None,
349
+ "include_screenshot": bool(args.screenshot),
350
+ }
351
+ res = _api_request("POST", "/fetch", payload, port=args.port, timeout=90.0)
352
+ if res is None:
353
+ # Fallback: run a one-shot coordinator in-process when the daemon is down
354
+ from .server import BrowserCoordinator
355
+
356
+ coord = BrowserCoordinator()
357
+ try:
358
+ res = coord.fetch(payload)
359
+ finally:
360
+ coord.shutdown()
361
+ if args.screenshot and res.get("screenshot_b64"):
362
+ Path(args.screenshot).write_bytes(base64.b64decode(res["screenshot_b64"]))
363
+ res["screenshot_saved"] = args.screenshot
364
+ res.pop("screenshot_b64", None)
365
+ if args.text:
366
+ print(res.get("text") or "")
367
+ elif args.html:
368
+ print(res.get("html") or "")
369
+ else:
370
+ out = {k: v for k, v in res.items() if k != "html"}
371
+ out["html_len"] = len(res.get("html") or "")
372
+ print(json.dumps(out, indent=2, ensure_ascii=False))
373
+ return 0 if res.get("ok") else 1
374
+
375
+ if args.cmd in ("click", "move", "type", "vnc"):
376
+ if args.cmd == "click":
377
+ act_payload = {"action": "click", "x": args.x, "y": args.y, "double": args.double}
378
+ elif args.cmd == "move":
379
+ act_payload = {"action": "move", "x": args.x, "y": args.y}
380
+ elif args.cmd == "type":
381
+ act_payload = {"action": "type", "text": args.text, "submit": args.submit}
382
+ else:
383
+ act_payload = {"action": f"vnc_{args.mode}"}
384
+ res = _api_request("POST", "/action", act_payload, port=args.port, timeout=30.0)
385
+ if res is None:
386
+ print("Error: browser-agent daemon is not running (start with: systemctl start browser-agent)", file=sys.stderr)
387
+ return 1
388
+ print(json.dumps(res, indent=2, ensure_ascii=False))
389
+ return 0 if res.get("ok") else 1
390
+
391
+ if args.cmd == "agent":
392
+ payload = {"url": args.url, "goal": args.goal, "max_steps": args.max_steps, "use_proxy": True if args.proxy else None}
393
+ res = _api_request("POST", "/agent", payload, port=args.port, timeout=180.0)
394
+ if res is None:
395
+ from .server import BrowserCoordinator
396
+
397
+ coord = BrowserCoordinator()
398
+ try:
399
+ res = coord.agent(payload)
400
+ finally:
401
+ coord.shutdown()
402
+ print(json.dumps(res, indent=2, ensure_ascii=False))
403
+ return 0 if res.get("ok") else 1
404
+
405
+ if args.cmd == "setup":
406
+ return cmd_setup(args)
407
+ if args.cmd == "doctor":
408
+ return cmd_doctor(args)
409
+
410
+ return 0
411
+
412
+
413
+ if __name__ == "__main__":
414
+ sys.exit(main())
@@ -0,0 +1,26 @@
1
+ [Unit]
2
+ Description=browser-agent: Real Chrome + X11 Mouse daemon (localhost:{{PORT}})
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User={{USER}}
9
+ EnvironmentFile=-{{ENV_FILE}}
10
+ Environment=BROWSER_AGENT_PORT={{PORT}}
11
+ Environment=BROWSER_AGENT_IDLE_SEC={{IDLE}}
12
+ # Memory discipline: the daemon must never starve co-tenant services on a
13
+ # small VPS. Chrome auto-hibernates after {{IDLE}}s of idle time (~12 MB
14
+ # resting state). Tune for your box with `browser-agent setup --memory-*`.
15
+ MemoryHigh={{MEMORY_HIGH}}
16
+ MemoryMax={{MEMORY_MAX}}
17
+ OOMScoreAdjust=500
18
+ ExecStart={{EXEC_START}} serve --port {{PORT}} --idle-timeout {{IDLE}}
19
+ Restart=on-failure
20
+ RestartSec=3
21
+ StandardOutput=journal
22
+ StandardError=journal
23
+ SyslogIdentifier=browser-agent
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target