nexus-browser 2.9.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.
nexus_browser/VERSION ADDED
@@ -0,0 +1 @@
1
+ 2.9.0
@@ -0,0 +1,3 @@
1
+ """NexusBrowser packaged for PyPI: the repository's client/ and native-host/ ship unchanged under this package, so the
2
+ MCP server finds its bridge modules exactly as it does in a checkout. The bridge and the Chrome extension still have to
3
+ be installed once on the PC (install.ps1 from the repository)."""
@@ -0,0 +1,17 @@
1
+ # pyright: reportMissingImports=false
2
+ # (nexus_browser.client exists only in the built wheel, where client/ is mirrored under this package.)
3
+ """Entry point of the `nexus-browser` command: the MCP server over stdio, as `python client/mcp_server.py` does."""
4
+
5
+
6
+ def main() -> None:
7
+ from nexus_browser.client import emergency_stop, mcp_server
8
+ from nexus_browser.client.bridge_client import start_heartbeat
9
+
10
+ # Shared emergency stop (Ctrl+Alt+Shift+Esc), session heartbeat, then the server (same order as mcp_server.py).
11
+ emergency_stop.start()
12
+ start_heartbeat()
13
+ mcp_server.browser_mcp.run(transport="stdio")
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
@@ -0,0 +1,383 @@
1
+ """Shared authenticated transport for NexusBrowser browser clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # Aucun __pycache__ à côté des sources (OneDrive), quel que soit le lanceur.
19
+ sys.dont_write_bytecode = True
20
+
21
+ REPOSITORY_ROOT = Path(__file__).resolve().parent.parent
22
+ NATIVE_HOST_DIRECTORY = REPOSITORY_ROOT / "native-host"
23
+ BRIDGE_SERVER_PATH = NATIVE_HOST_DIRECTORY / "bridge_server.py"
24
+ API_URL = "http://127.0.0.1:9225/api/command"
25
+ STATUS_URL = "http://127.0.0.1:9225/api/status"
26
+
27
+ sys.path.insert(0, str(NATIVE_HOST_DIRECTORY))
28
+
29
+ # Aucun repli : sans ce module, pas de jeton ni de liste des lectures ; l'erreur d'import remonte telle quelle.
30
+ from bridge_security import ( # noqa: E402
31
+ AGENT_HEADER,
32
+ CLIENT_HEADER,
33
+ PID_HEADER,
34
+ READ_ONLY_ACTIONS,
35
+ TOKEN_HEADER,
36
+ check_identity,
37
+ check_upload_files,
38
+ is_read_only_action,
39
+ is_upload_action,
40
+ protected_upload_roots,
41
+ read_token,
42
+ )
43
+
44
+ __all__ = [
45
+ "READ_ONLY_ACTIONS",
46
+ "TOKEN_HEADER",
47
+ "call_bridge",
48
+ "check_upload_files",
49
+ "is_read_only_action",
50
+ "protected_upload_roots",
51
+ "set_identity",
52
+ ]
53
+
54
+ # Identité de CE client auprès du pont : nom de l'agent et identifiant de sa session. L'extension rattache à cette
55
+ # identité les onglets ouverts par browser_new_tab et refuse toute commande de page sur les autres. Le serveur MCP
56
+ # d'un agent la fixe une fois au démarrage (un identifiant neuf par processus) ; la CLI garde un identifiant stable
57
+ # (« CLI » / « cli-local ») pour retrouver ses onglets d'un appel à l'autre.
58
+ _identity: dict[str, str] = {
59
+ "agent": os.environ.get("NEXUS_BROWSER_AGENT", "").strip() or "CLI",
60
+ "id": os.environ.get("NEXUS_BROWSER_CLIENT", "").strip() or "cli-local",
61
+ }
62
+ # Processus annoncé au pont : seulement pour une session liée à CE processus (set_identity). Quand il disparaît
63
+ # (serveur MCP redémarré, planté), ses onglets deviennent orphelins et le même agent peut les reprendre. La CLI, à
64
+ # identité stable d'un appel à l'autre, n'en annonce aucun.
65
+ _session_pid: int | None = None
66
+
67
+
68
+ def set_identity(agent: str, client_id: str | None = None) -> dict[str, str]:
69
+ """Fixe l'identité de ce processus ; client_id absent : identifiant neuf (session unique, liée à ce processus)."""
70
+ global _session_pid
71
+ identity = check_identity(agent, client_id or f"{agent.lower().replace(' ', '-')}-{uuid.uuid4().hex[:12]}")
72
+ if identity is None:
73
+ raise ValueError(f"Identité de client invalide : agent={agent!r}, client={client_id!r}")
74
+ _identity.update(identity)
75
+ _session_pid = os.getpid()
76
+ return dict(_identity)
77
+
78
+
79
+ def identity() -> dict[str, str]:
80
+ return dict(_identity)
81
+
82
+
83
+ def authentication_headers() -> dict[str, str]:
84
+ """Jeton relu à chaque commande (une rotation prend effet aussitôt), en LECTURE seule : ses droits sont réglés à
85
+ sa création par le pont, jamais ici (aucun whoami ni icacls par commande). Identité du client jointe, et son
86
+ processus pour une session liée à ce processus."""
87
+ headers = {TOKEN_HEADER: read_token(), AGENT_HEADER: _identity["agent"], CLIENT_HEADER: _identity["id"]}
88
+ if _session_pid is not None:
89
+ headers[PID_HEADER] = str(_session_pid)
90
+ return headers
91
+
92
+
93
+ # Signe de vie d'une session (serveur MCP) : sans commande ni battement pendant SESSION_DEAD_S, l'extension la tient
94
+ # pour morte et ses onglets deviennent orphelins. Battement toutes les 60 / 1,618 s, silence toléré 1,618 x 60 s.
95
+ HEARTBEAT_S = 60 / 1.618
96
+ SESSION_DEAD_S = 1.618 * 60
97
+
98
+
99
+ def heartbeat(reclaim: bool = False) -> Any:
100
+ """Un battement : jamais de démarrage du pont ni de Chrome, aucun nouvel essai. reclaim=True : l'extension confie
101
+ à cette session les onglets orphelins de son agent si elle n'en a aucun (reprise après redémarrage)."""
102
+ return call_bridge("heartbeat", {"reclaim": bool(reclaim)}, 5.0, retry=False)
103
+
104
+
105
+ def start_heartbeat(interval_s: float = HEARTBEAT_S, stop: threading.Event | None = None) -> threading.Thread:
106
+ """Battement de fond du serveur MCP (jusqu'à stop) ; le premier réussi reprend les onglets orphelins de l'agent."""
107
+ stop = stop or threading.Event()
108
+
109
+ def beat() -> None:
110
+ reclaim = True
111
+ while not stop.is_set():
112
+ try:
113
+ reclaimed = (heartbeat(reclaim) or {}).get("reclaimed") or []
114
+ if reclaimed:
115
+ print(f"[nexus-browser] Onglets orphelins repris : {reclaimed}", file=sys.stderr, flush=True)
116
+ reclaim = False
117
+ except Exception: # pont arrêté, Chrome fermé : réessayé au battement suivant
118
+ pass
119
+ stop.wait(interval_s)
120
+
121
+ thread = threading.Thread(target=beat, name="nexus-browser-heartbeat", daemon=True)
122
+ thread.start()
123
+ return thread
124
+
125
+
126
+ def bridge_is_running() -> bool:
127
+ """Probe the authenticated bridge status endpoint."""
128
+ try:
129
+ request = urllib.request.Request(STATUS_URL, headers=authentication_headers())
130
+ with urllib.request.urlopen(request, timeout=1.0) as response:
131
+ return response.status == 200
132
+ except (urllib.error.URLError, TimeoutError, OSError):
133
+ return False
134
+
135
+
136
+ def ensure_bridge_running() -> bool:
137
+ """Ask the single Windows supervisor to start the bridge."""
138
+ if bridge_is_running():
139
+ return True
140
+ if not BRIDGE_SERVER_PATH.exists():
141
+ return False
142
+
143
+ if sys.platform != "win32":
144
+ return False
145
+ try:
146
+ subprocess.run(
147
+ ["schtasks.exe", "/Run", "/TN", "Nexus Browser Bridge"],
148
+ check=True,
149
+ capture_output=True,
150
+ creationflags=subprocess.CREATE_NO_WINDOW,
151
+ timeout=5,
152
+ )
153
+ except (OSError, subprocess.SubprocessError):
154
+ return False
155
+
156
+ for _ in range(30):
157
+ time.sleep(0.1)
158
+ if bridge_is_running():
159
+ return True
160
+ return False
161
+
162
+
163
+ def is_chrome_running() -> bool:
164
+ """Check if any chrome.exe process is currently alive."""
165
+ if sys.platform != "win32":
166
+ return False
167
+ try:
168
+ output = subprocess.check_output(
169
+ ["tasklist.exe", "/FI", "IMAGENAME eq chrome.exe", "/FO", "CSV"],
170
+ creationflags=subprocess.CREATE_NO_WINDOW,
171
+ text=True,
172
+ timeout=3,
173
+ )
174
+ return "chrome.exe" in output.lower()
175
+ except (OSError, subprocess.SubprocessError):
176
+ return False
177
+
178
+
179
+ FIXED_CHROME_PATHS = [
180
+ Path("C:/Program Files/Google/Chrome/Application/chrome.exe"),
181
+ Path("C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"),
182
+ ]
183
+ CHROME_APP_PATHS_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"
184
+
185
+
186
+ def _registered_chrome() -> list[Path]:
187
+ """Chemins déclarés par l'installeur de Chrome dans App Paths (utilisateur puis machine)."""
188
+ if sys.platform != "win32":
189
+ return []
190
+ import winreg
191
+
192
+ found = []
193
+ for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
194
+ try:
195
+ with winreg.OpenKey(hive, CHROME_APP_PATHS_KEY) as key:
196
+ value, _ = winreg.QueryValueEx(key, "")
197
+ except OSError:
198
+ continue
199
+ if isinstance(value, str) and value.strip():
200
+ found.append(Path(os.path.expandvars(value.strip().strip('"'))))
201
+ return found
202
+
203
+
204
+ def get_chrome_executable() -> Path | None:
205
+ """Locate the Google Chrome executable on Windows : registre App Paths, puis installations machine et utilisateur."""
206
+ candidates = [*_registered_chrome()]
207
+ for variable in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
208
+ base = os.environ.get(variable)
209
+ if base:
210
+ candidates.append(Path(base) / "Google" / "Chrome" / "Application" / "chrome.exe")
211
+ candidates += FIXED_CHROME_PATHS
212
+ for candidate in candidates:
213
+ if candidate.is_file():
214
+ return candidate
215
+ return None
216
+
217
+
218
+ # Relance de Chrome : profil par défaut seulement. Arguments en plus : variable d'environnement
219
+ # NEXUS_CHROME_EXTRA_ARGS, des drapeaux séparés par des espaces, pris dans une LISTE BLANCHE (langue, pays des
220
+ # variations, thème sombre, fonctions Glic désactivées) ; tout autre élément est ignoré (une liste noire laissait passer
221
+ # des drapeaux qui affaiblissent Chrome, comme --disable-web-security ; audit du 18/10).
222
+ CHROME_BASE_ARGS: tuple[str, ...] = ("--profile-directory=Default",)
223
+ EXTRA_ARGS_VARIABLE = "NEXUS_CHROME_EXTRA_ARGS"
224
+ _ALLOWED_FLAGS: dict[str, re.Pattern[str] | None] = {
225
+ "--lang": re.compile(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*"),
226
+ "--accept-lang": re.compile(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*(,[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)*"),
227
+ "--variations-override-country": re.compile(r"[A-Za-z]{2}"),
228
+ "--disable-features": re.compile(r"Glic\w*(,Glic\w*)*"),
229
+ "--force-dark-mode": None,
230
+ }
231
+
232
+
233
+ def chrome_extra_args(value: str | None = None) -> list[str]:
234
+ """Drapeaux supplémentaires lus dans NEXUS_CHROME_EXTRA_ARGS (vide par défaut), liste blanche seulement."""
235
+ raw = os.environ.get(EXTRA_ARGS_VARIABLE, "") if value is None else value
236
+ accepted: list[str] = []
237
+ for item in raw.split():
238
+ name, sep, flag_value = item.partition("=")
239
+ if name not in _ALLOWED_FLAGS:
240
+ continue
241
+ expected = _ALLOWED_FLAGS[name]
242
+ valid = not sep if expected is None else bool(sep) and expected.fullmatch(flag_value) is not None
243
+ if valid:
244
+ accepted.append(item)
245
+ return accepted
246
+
247
+
248
+ def chrome_launch_args() -> list[str]:
249
+ return [*CHROME_BASE_ARGS, *chrome_extra_args()]
250
+
251
+
252
+ def launch_chrome() -> bool:
253
+ """Relance Google Chrome normalement sur le profil Default, avec les drapeaux de NEXUS_CHROME_EXTRA_ARGS.
254
+
255
+ L'extension Nexus Browser Bridge doit y être installée et activée : Chrome stable n'accepte plus
256
+ --load-extension, ce chemin ne sert donc plus de relance. Vrai si le processus a été lancé (la connexion de
257
+ l'extension est vérifiée ensuite par ensure_chrome_running).
258
+ """
259
+ chrome_exe = get_chrome_executable()
260
+ if not chrome_exe:
261
+ return False
262
+ try:
263
+ subprocess.Popen(
264
+ [str(chrome_exe), *chrome_launch_args()],
265
+ creationflags=getattr(subprocess, "DETACHED_PROCESS", 0),
266
+ )
267
+ return True
268
+ except OSError:
269
+ return False
270
+
271
+
272
+ def ping_bridge() -> bool:
273
+ """Test if the Chrome extension is connected and responding to ping."""
274
+ payload = {"action": "ping", "params": {}, "timeout": 1.0}
275
+ try:
276
+ request = urllib.request.Request(
277
+ API_URL,
278
+ data=json.dumps(payload).encode("utf-8"),
279
+ headers={"Content-Type": "application/json", **authentication_headers()},
280
+ )
281
+ with urllib.request.urlopen(request, timeout=1.5) as response:
282
+ data = json.loads(response.read().decode("utf-8"))
283
+ return bool(data.get("success"))
284
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
285
+ return False
286
+
287
+
288
+ EXTENSION_NOT_CONNECTED = (
289
+ "L'extension Nexus Browser Bridge ne s'est pas connectée au pont NexusBrowser{detail}. Vérifier qu'elle est "
290
+ "installée et activée dans chrome://extensions sur le profil Default (Chrome stable n'accepte plus "
291
+ "--load-extension : elle doit être chargée une fois depuis le dossier extension du dépôt)."
292
+ )
293
+
294
+
295
+ def ensure_chrome_running(timeout: float = 6.0) -> bool:
296
+ """Ensure Chrome is started and the extension has established its bridge connection."""
297
+ ensure_bridge_running()
298
+
299
+ if not is_chrome_running():
300
+ launch_chrome()
301
+
302
+ deadline = time.time() + timeout
303
+ while time.time() < deadline:
304
+ if ping_bridge():
305
+ return True
306
+ time.sleep(0.25)
307
+ return False
308
+
309
+
310
+ # Téléversements : jamais un fichier des dossiers de secrets de Julien (bridge_security.upload_refusal). Contrôlé ici
311
+ # (avant tout envoi) ET par le pont lui-même, pour tout client HTTP.
312
+
313
+
314
+ def _not_delivered(error: urllib.error.URLError) -> bool:
315
+ """Vrai si la requête n'a certainement pas atteint le pont (connexion refusée) : la renvoyer est sans risque."""
316
+ reason = getattr(error, "reason", None)
317
+ return isinstance(reason, ConnectionRefusedError)
318
+
319
+
320
+ def call_bridge(action: str, params: dict[str, Any] | None = None, timeout: float = 20.0, *, retry: bool = True) -> Any:
321
+ """Execute one browser action and return its decoded result.
322
+
323
+ Nouvel essai unique, seulement quand c'est sûr : la commande n'a pas été remise (pont arrêté, extension non
324
+ connectée), ou c'est une lecture. Après un délai dépassé, une action (clic, saisie, navigation...) n'est jamais
325
+ renvoyée : elle a pu être exécutée.
326
+ """
327
+ if is_upload_action(action):
328
+ check_upload_files((params or {}).get("files"))
329
+ payload = {"action": action, "params": params or {}, "timeout": timeout}
330
+ try:
331
+ headers = authentication_headers()
332
+ except FileNotFoundError:
333
+ # Jeton pas encore créé : c'est le pont qui le crée (droits restreints) à son démarrage.
334
+ if not (retry and ensure_bridge_running()):
335
+ raise ConnectionError(
336
+ f"Impossible de joindre le pont NexusBrowser sur {API_URL} : jeton absent et démarrage du pont échoué. "
337
+ "Pont et extension pas encore installés sur ce PC ? Une fois : install.ps1 "
338
+ "(https://github.com/pironjulien/NexusBrowser#installation)."
339
+ ) from None
340
+ headers = authentication_headers()
341
+ request = urllib.request.Request(
342
+ API_URL,
343
+ data=json.dumps(payload).encode("utf-8"),
344
+ headers={"Content-Type": "application/json", **headers},
345
+ )
346
+ try:
347
+ with urllib.request.urlopen(request, timeout=timeout + 2) as response:
348
+ data = json.loads(response.read().decode("utf-8"))
349
+ except urllib.error.HTTPError as error:
350
+ try:
351
+ try:
352
+ payload = json.loads(error.read().decode("utf-8"))
353
+ message = payload.get("error", f"HTTP {error.code}: {error.reason}")
354
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
355
+ message = f"HTTP {error.code}: {error.reason}"
356
+ finally:
357
+ error.close()
358
+
359
+ if "No Chrome browser extension connected" in message:
360
+ # Commande non remise : relancer Chrome (normalement) puis la renvoyer une fois est sans risque.
361
+ if retry and ensure_chrome_running():
362
+ return call_bridge(action, params, timeout, retry=False)
363
+ detail = "" if is_chrome_running() else " (Chrome n'a pas pu être relancé)"
364
+ raise RuntimeError(EXTENSION_NOT_CONNECTED.format(detail=detail)) from error
365
+
366
+ raise RuntimeError(message) from error
367
+ except (urllib.error.URLError, TimeoutError) as error:
368
+ delivered = not (isinstance(error, urllib.error.URLError) and _not_delivered(error))
369
+ if retry and (not delivered or is_read_only_action(action)) and ensure_bridge_running():
370
+ return call_bridge(action, params, timeout, retry=False)
371
+ if delivered and not is_read_only_action(action):
372
+ raise ConnectionError(
373
+ f"Pas de réponse du pont NexusBrowser pour '{action}' dans le délai : la commande a pu être exécutée. "
374
+ "Elle n'est pas renvoyée automatiquement (risque de double action) ; vérifier l'état de la page "
375
+ "avant de la relancer."
376
+ ) from error
377
+ raise ConnectionError(
378
+ f"Impossible de joindre le pont NexusBrowser sur {API_URL}. Le démarrage automatique a échoué."
379
+ ) from error
380
+
381
+ if not data.get("success"):
382
+ raise RuntimeError(data.get("error", "Erreur inconnue du pont NexusBrowser"))
383
+ return data.get("data")