zerochat 9.9.0__tar.gz → 9.11.0__tar.gz
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.
Potentially problematic release.
This version of zerochat might be problematic. Click here for more details.
- {zerochat-9.9.0/zerochat.egg-info → zerochat-9.11.0}/PKG-INFO +1 -1
- {zerochat-9.9.0 → zerochat-9.11.0}/pyproject.toml +1 -1
- {zerochat-9.9.0 → zerochat-9.11.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-9.9.0 → zerochat-9.11.0}/zerochat.py +496 -260
- {zerochat-9.9.0 → zerochat-9.11.0}/LICENSE +0 -0
- {zerochat-9.9.0 → zerochat-9.11.0}/README.md +0 -0
- {zerochat-9.9.0 → zerochat-9.11.0}/setup.cfg +0 -0
- {zerochat-9.9.0 → zerochat-9.11.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-9.9.0 → zerochat-9.11.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-9.9.0 → zerochat-9.11.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-9.9.0 → zerochat-9.11.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ ZeroChat - Backend Local Unificado y Gestor de Entorno
|
|
|
4
4
|
|
|
5
5
|
Proporciona:
|
|
6
6
|
1. Auto-creación del entorno MCP local `~/zerochat/.venv`.
|
|
7
|
-
2. Servidor local HTTP
|
|
7
|
+
2. Servidor local HTTP JSON-RPC con autenticación estricta por token diario e identificación con la cuenta del sistema.
|
|
8
8
|
3. Herramientas locales seguras: read_file, edit_file, list_directory, run_command.
|
|
9
9
|
4. Apertura automática del navegador apuntando a zerochat.html con token en el fragmento hash.
|
|
10
10
|
"""
|
|
@@ -14,14 +14,19 @@ from __future__ import annotations
|
|
|
14
14
|
import argparse
|
|
15
15
|
import atexit
|
|
16
16
|
import base64
|
|
17
|
+
import ctypes
|
|
18
|
+
import ctypes.util
|
|
19
|
+
import dataclasses
|
|
17
20
|
import datetime
|
|
18
21
|
import fnmatch
|
|
19
22
|
import functools
|
|
23
|
+
import getpass
|
|
20
24
|
import hashlib
|
|
21
25
|
import hmac
|
|
22
26
|
import importlib.metadata
|
|
23
27
|
import ipaddress
|
|
24
28
|
import json
|
|
29
|
+
import math
|
|
25
30
|
import os
|
|
26
31
|
import queue
|
|
27
32
|
import re
|
|
@@ -46,7 +51,7 @@ import zlib
|
|
|
46
51
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
52
|
from pathlib import Path
|
|
48
53
|
|
|
49
|
-
SOURCE_BACKEND_VERSION = "9.
|
|
54
|
+
SOURCE_BACKEND_VERSION = "9.11.0"
|
|
50
55
|
|
|
51
56
|
def _read_source_version(filename: str) -> str | None:
|
|
52
57
|
"""Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
|
|
@@ -104,10 +109,11 @@ NOTICES: list[str] = []
|
|
|
104
109
|
NOTICES_LOCK = threading.Lock()
|
|
105
110
|
class ConsoleControl:
|
|
106
111
|
"""Muestra una sola línea de ayuda tras un periodo de inactividad en consola."""
|
|
107
|
-
def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser,
|
|
112
|
+
def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser, launch_url=None):
|
|
108
113
|
self.server = server
|
|
109
114
|
self.parser = parser
|
|
110
|
-
|
|
115
|
+
# Función que da la URL de la interfaz con el token vigente al pulsar n, no el del arranque.
|
|
116
|
+
self.launch_url = launch_url
|
|
111
117
|
self.last_activity = time.monotonic()
|
|
112
118
|
self.lock = threading.Lock()
|
|
113
119
|
self.enabled = bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
@@ -176,8 +182,8 @@ class ConsoleControl:
|
|
|
176
182
|
key = command.strip().lower()
|
|
177
183
|
if key == "h":
|
|
178
184
|
self.show_help()
|
|
179
|
-
elif key == "n" and self.
|
|
180
|
-
launch_browser(self.
|
|
185
|
+
elif key == "n" and self.launch_url:
|
|
186
|
+
launch_browser(self.launch_url())
|
|
181
187
|
elif key == "i":
|
|
182
188
|
self.show_notices()
|
|
183
189
|
elif key == "x":
|
|
@@ -308,15 +314,28 @@ def get_daily_token() -> str:
|
|
|
308
314
|
return token
|
|
309
315
|
|
|
310
316
|
|
|
311
|
-
# Token de sesión en memoria; main() lo carga
|
|
317
|
+
# Token de sesión en memoria y su día; main() lo carga al arrancar y current_session_token() lo renueva.
|
|
312
318
|
SESSION_TOKEN = ""
|
|
319
|
+
SESSION_TOKEN_DATE = ""
|
|
320
|
+
SESSION_TOKEN_LOCK = threading.Lock()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def current_session_token() -> str:
|
|
324
|
+
"""Token diario vigente: al cambiar el día carga o genera el nuevo, también con el servidor en marcha."""
|
|
325
|
+
global SESSION_TOKEN, SESSION_TOKEN_DATE
|
|
326
|
+
today = datetime.date.today().isoformat()
|
|
327
|
+
with SESSION_TOKEN_LOCK:
|
|
328
|
+
if SESSION_TOKEN_DATE != today:
|
|
329
|
+
SESSION_TOKEN = get_daily_token()
|
|
330
|
+
SESSION_TOKEN_DATE = today
|
|
331
|
+
return SESSION_TOKEN
|
|
313
332
|
ACTIVE_PORT = DEFAULT_PORT
|
|
314
333
|
ACTIVE_HOST = DEFAULT_HOST
|
|
315
334
|
|
|
316
335
|
# Kept only for this Python process: binds an approved browser tool call to
|
|
317
336
|
# the exact JSON-RPC bytes sent to the local host.
|
|
318
337
|
TOOL_AUTH_VERSION = "zerochat-tool-auth-v1"
|
|
319
|
-
TOOL_AUTH_TTL_MS =
|
|
338
|
+
TOOL_AUTH_TTL_MS = 60_000
|
|
320
339
|
TOOL_AUTH_MAX_NONCES = 10_000
|
|
321
340
|
TOOL_AUTH_KEY = secrets.token_bytes(32)
|
|
322
341
|
TOOL_AUTH_SESSION_ID = secrets.token_urlsafe(18)
|
|
@@ -569,7 +588,6 @@ class _WindowsKillOnCloseJob:
|
|
|
569
588
|
self._handle = None
|
|
570
589
|
self._kernel32 = None
|
|
571
590
|
try:
|
|
572
|
-
import ctypes
|
|
573
591
|
from ctypes import wintypes
|
|
574
592
|
|
|
575
593
|
class BasicLimits(ctypes.Structure):
|
|
@@ -2532,6 +2550,150 @@ class BrowserEngines:
|
|
|
2532
2550
|
|
|
2533
2551
|
BROWSER_ENGINES = BrowserEngines()
|
|
2534
2552
|
# ==============================================================================
|
|
2553
|
+
# Identificación con el usuario y la contraseña del sistema (zerochat/login)
|
|
2554
|
+
# ==============================================================================
|
|
2555
|
+
|
|
2556
|
+
# Tras LOGIN_MAX_FAILURES fallos seguidos, los intentos se rechazan sin comprobarlos durante el bloqueo.
|
|
2557
|
+
LOGIN_MAX_FAILURES = 5
|
|
2558
|
+
LOGIN_LOCKOUT_SECONDS = 900.0
|
|
2559
|
+
LOGIN_MAX_FIELD_LENGTH = 512
|
|
2560
|
+
# Sin token solo se lee un cuerpo de este tamaño, el justo para zerochat/login.
|
|
2561
|
+
LOGIN_MAX_BODY_BYTES = 4096
|
|
2562
|
+
LOGIN_FAILED_CODE = -32001
|
|
2563
|
+
LOGIN_LOCKED_CODE = -32002
|
|
2564
|
+
LOGIN_PAM_SERVICE = "login"
|
|
2565
|
+
LOGIN_LOCK = threading.Lock()
|
|
2566
|
+
LOGIN_FAILURES = 0
|
|
2567
|
+
LOGIN_LOCKED_UNTIL = 0.0
|
|
2568
|
+
|
|
2569
|
+
|
|
2570
|
+
class LoginError(Exception):
|
|
2571
|
+
"""Identificación rechazada: `code` es el error JSON-RPC y `data`, lo que necesita la interfaz."""
|
|
2572
|
+
def __init__(self, code: int, message: str, data: dict | None = None):
|
|
2573
|
+
super().__init__(message)
|
|
2574
|
+
self.code = code
|
|
2575
|
+
self.data = data
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
def system_account() -> str:
|
|
2579
|
+
"""Cuenta que ejecuta zerochat.py, la única que puede identificarse."""
|
|
2580
|
+
if os.name == "nt":
|
|
2581
|
+
return getpass.getuser()
|
|
2582
|
+
import pwd
|
|
2583
|
+
return pwd.getpwuid(os.getuid()).pw_name
|
|
2584
|
+
|
|
2585
|
+
|
|
2586
|
+
def _pam_authenticate(user: str, password: str) -> bool:
|
|
2587
|
+
"""PAM en Linux y macOS: un proceso sin privilegios comprueba su propia cuenta (unix_chkpwd en Linux)."""
|
|
2588
|
+
libpam_path = ctypes.util.find_library("pam")
|
|
2589
|
+
if not libpam_path:
|
|
2590
|
+
raise RuntimeError("PAM no está disponible en este sistema.")
|
|
2591
|
+
libpam = ctypes.CDLL(libpam_path)
|
|
2592
|
+
libc = ctypes.CDLL(ctypes.util.find_library("c"))
|
|
2593
|
+
libc.calloc.restype = ctypes.c_void_p
|
|
2594
|
+
libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t]
|
|
2595
|
+
libc.strdup.restype = ctypes.c_void_p
|
|
2596
|
+
libc.strdup.argtypes = [ctypes.c_char_p]
|
|
2597
|
+
|
|
2598
|
+
class PamMessage(ctypes.Structure):
|
|
2599
|
+
_fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)]
|
|
2600
|
+
|
|
2601
|
+
class PamResponse(ctypes.Structure):
|
|
2602
|
+
_fields_ = [("resp", ctypes.c_void_p), ("resp_retcode", ctypes.c_int)]
|
|
2603
|
+
|
|
2604
|
+
conv_func = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.POINTER(PamMessage)),
|
|
2605
|
+
ctypes.POINTER(ctypes.POINTER(PamResponse)), ctypes.c_void_p)
|
|
2606
|
+
|
|
2607
|
+
class PamConv(ctypes.Structure):
|
|
2608
|
+
_fields_ = [("conv", conv_func), ("appdata_ptr", ctypes.c_void_p)]
|
|
2609
|
+
|
|
2610
|
+
login_name = user.encode("utf-8")
|
|
2611
|
+
secret = password.encode("utf-8")
|
|
2612
|
+
|
|
2613
|
+
def conversation(count, messages, response, _appdata):
|
|
2614
|
+
# PAM libera las respuestas: se reservan con la libc, no con Python.
|
|
2615
|
+
replies = libc.calloc(count, ctypes.sizeof(PamResponse))
|
|
2616
|
+
if not replies:
|
|
2617
|
+
return 5 # PAM_BUF_ERR
|
|
2618
|
+
array = ctypes.cast(replies, ctypes.POINTER(PamResponse))
|
|
2619
|
+
for index in range(count):
|
|
2620
|
+
style = messages[index].contents.msg_style
|
|
2621
|
+
if style == 1: # PAM_PROMPT_ECHO_OFF: la contraseña
|
|
2622
|
+
array[index].resp = libc.strdup(secret)
|
|
2623
|
+
elif style == 2: # PAM_PROMPT_ECHO_ON: el usuario
|
|
2624
|
+
array[index].resp = libc.strdup(login_name)
|
|
2625
|
+
response[0] = array
|
|
2626
|
+
return 0
|
|
2627
|
+
|
|
2628
|
+
libpam.pam_start.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(PamConv), ctypes.POINTER(ctypes.c_void_p)]
|
|
2629
|
+
libpam.pam_authenticate.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
2630
|
+
libpam.pam_acct_mgmt.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
2631
|
+
libpam.pam_end.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
2632
|
+
callback = conv_func(conversation)
|
|
2633
|
+
conv = PamConv(callback, None)
|
|
2634
|
+
handle = ctypes.c_void_p()
|
|
2635
|
+
status = libpam.pam_start(LOGIN_PAM_SERVICE.encode("ascii"), login_name, ctypes.byref(conv), ctypes.byref(handle))
|
|
2636
|
+
if status != 0:
|
|
2637
|
+
raise RuntimeError(f"PAM no pudo iniciarse (código {status}).")
|
|
2638
|
+
try:
|
|
2639
|
+
status = libpam.pam_authenticate(handle, 0)
|
|
2640
|
+
if status == 0:
|
|
2641
|
+
# Una cuenta caducada o bloqueada no se identifica aunque la contraseña sea correcta.
|
|
2642
|
+
status = libpam.pam_acct_mgmt(handle, 0)
|
|
2643
|
+
finally:
|
|
2644
|
+
libpam.pam_end(handle, status)
|
|
2645
|
+
return status == 0
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
def _windows_logon(user: str, password: str) -> bool:
|
|
2649
|
+
"""LogonUserW de advapi32 con un inicio de sesión de red, que comprueba la contraseña sin cargar el perfil."""
|
|
2650
|
+
from ctypes import wintypes
|
|
2651
|
+
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
2652
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
2653
|
+
advapi32.LogonUserW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
|
2654
|
+
advapi32.LogonUserW.restype = wintypes.BOOL
|
|
2655
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
2656
|
+
token = wintypes.HANDLE()
|
|
2657
|
+
# LOGON32_LOGON_NETWORK (3) y LOGON32_PROVIDER_DEFAULT (0).
|
|
2658
|
+
if not advapi32.LogonUserW(user, None, password, 3, 0, ctypes.byref(token)):
|
|
2659
|
+
return False
|
|
2660
|
+
kernel32.CloseHandle(token)
|
|
2661
|
+
return True
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def check_system_password(user: str, password: str) -> bool:
|
|
2665
|
+
"""Comprueba la contraseña de una cuenta del sistema."""
|
|
2666
|
+
return _windows_logon(user, password) if os.name == "nt" else _pam_authenticate(user, password)
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
def login(params: dict) -> dict:
|
|
2670
|
+
"""
|
|
2671
|
+
zerochat/login: con la cuenta que ejecuta zerochat.py y su contraseña devuelve el token diario.
|
|
2672
|
+
Tras LOGIN_MAX_FAILURES fallos seguidos rechaza los intentos sin comprobarlos durante
|
|
2673
|
+
LOGIN_LOCKOUT_SECONDS; los intentos van de uno en uno.
|
|
2674
|
+
"""
|
|
2675
|
+
global LOGIN_FAILURES, LOGIN_LOCKED_UNTIL
|
|
2676
|
+
user = params.get("user")
|
|
2677
|
+
password = params.get("password")
|
|
2678
|
+
if not all(isinstance(value, str) and 0 < len(value) <= LOGIN_MAX_FIELD_LENGTH for value in (user, password)):
|
|
2679
|
+
raise LoginError(-32602, f"user y password deben ser textos de 1 a {LOGIN_MAX_FIELD_LENGTH} caracteres.")
|
|
2680
|
+
with LOGIN_LOCK:
|
|
2681
|
+
remaining = LOGIN_LOCKED_UNTIL - time.monotonic()
|
|
2682
|
+
if remaining > 0:
|
|
2683
|
+
raise LoginError(LOGIN_LOCKED_CODE, "Demasiados intentos fallidos: espera para volver a intentarlo.", {"retryAfter": math.ceil(remaining)})
|
|
2684
|
+
account = system_account()
|
|
2685
|
+
same_account = user.casefold() == account.casefold() if os.name == "nt" else user == account
|
|
2686
|
+
# La contraseña se comprueba también con otro usuario, para que la respuesta no delate la cuenta.
|
|
2687
|
+
valid = check_system_password(account, password)
|
|
2688
|
+
if not (valid and same_account):
|
|
2689
|
+
LOGIN_FAILURES += 1
|
|
2690
|
+
if LOGIN_FAILURES >= LOGIN_MAX_FAILURES:
|
|
2691
|
+
LOGIN_FAILURES = 0
|
|
2692
|
+
LOGIN_LOCKED_UNTIL = time.monotonic() + LOGIN_LOCKOUT_SECONDS
|
|
2693
|
+
raise LoginError(LOGIN_FAILED_CODE, "Usuario o contraseña incorrectos.")
|
|
2694
|
+
LOGIN_FAILURES = 0
|
|
2695
|
+
return {"token": current_session_token()}
|
|
2696
|
+
# ==============================================================================
|
|
2535
2697
|
# Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
|
|
2536
2698
|
# ==============================================================================
|
|
2537
2699
|
|
|
@@ -2888,6 +3050,26 @@ class StdioMcpClient:
|
|
|
2888
3050
|
self.tools = []
|
|
2889
3051
|
|
|
2890
3052
|
|
|
3053
|
+
@dataclasses.dataclass
|
|
3054
|
+
class McpServiceRecord:
|
|
3055
|
+
"""Estado de un servicio MCP en este proceso: fase del ciclo de vida, cliente, último error y
|
|
3056
|
+
modo y navegador con los que arrancó (solo los servicios de SERVICE_LAUNCH)."""
|
|
3057
|
+
phase: str = "stopped" # stopped, installing, starting, running o error
|
|
3058
|
+
client: StdioMcpClient | None = None
|
|
3059
|
+
error: str | None = None
|
|
3060
|
+
browser: dict | None = None
|
|
3061
|
+
|
|
3062
|
+
|
|
3063
|
+
def service_status(record: McpServiceRecord, alive: bool, exit_code: int | None) -> tuple[str, str | None]:
|
|
3064
|
+
"""Estado público y error de un servicio a partir de su registro y de si su proceso sigue vivo:
|
|
3065
|
+
el proceso de un servicio en marcha puede morir sin que nadie lo detenga."""
|
|
3066
|
+
if record.phase != "running" or alive:
|
|
3067
|
+
return record.phase, record.error
|
|
3068
|
+
if exit_code is not None:
|
|
3069
|
+
return "error", f"MCP process exited with code {exit_code}"
|
|
3070
|
+
return "error", "MCP process is not responding"
|
|
3071
|
+
|
|
3072
|
+
|
|
2891
3073
|
class McpServiceManager:
|
|
2892
3074
|
def __init__(self, services_root: Path | None = None):
|
|
2893
3075
|
if services_root:
|
|
@@ -2897,11 +3079,7 @@ class McpServiceManager:
|
|
|
2897
3079
|
self.services_root.mkdir(parents=True, exist_ok=True)
|
|
2898
3080
|
self.config_file = get_data_dir() / "config" / "services.json"
|
|
2899
3081
|
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2900
|
-
self.
|
|
2901
|
-
self.states: dict[str, str] = {}
|
|
2902
|
-
self.errors: dict[str, str] = {}
|
|
2903
|
-
# Modo y navegador con los que arrancó cada servicio de SERVICE_LAUNCH.
|
|
2904
|
-
self.browsers: dict[str, dict] = {}
|
|
3082
|
+
self.records: dict[str, McpServiceRecord] = {}
|
|
2905
3083
|
self._lock = threading.Lock()
|
|
2906
3084
|
materialize_managed_services(self.services_root)
|
|
2907
3085
|
self.services = self._load_services()
|
|
@@ -2945,19 +3123,22 @@ class McpServiceManager:
|
|
|
2945
3123
|
except OSError as err:
|
|
2946
3124
|
log_event(f"[zerochat] Advertencia al guardar preferencias MCP en {self.config_file}: {err}")
|
|
2947
3125
|
|
|
3126
|
+
def _remember(self, server_id: str, enabled: bool):
|
|
3127
|
+
"""Único sitio que guarda si un servicio queda activo para ofrecerlo en la siguiente sesión: lo
|
|
3128
|
+
marca un arranque con remember y lo desmarca detenerlo. Un reinicio (detener y arrancar con el
|
|
3129
|
+
valor previo) lo conserva, y un arranque sin remember, como el de un perfil, no lo cambia."""
|
|
3130
|
+
self.preferences.setdefault(server_id, {})["enabled"] = enabled
|
|
3131
|
+
self._save_preferences()
|
|
3132
|
+
|
|
2948
3133
|
def list_servers(self) -> list[dict]:
|
|
2949
3134
|
self.services = self._load_services()
|
|
2950
3135
|
result = []
|
|
2951
3136
|
for server_id, server in self.services.items():
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
error =
|
|
2956
|
-
|
|
2957
|
-
if state == "running" and client and not running:
|
|
2958
|
-
code = client.process.poll() if client.process else None
|
|
2959
|
-
state = "error"
|
|
2960
|
-
error = f"MCP process exited with code {code}" if code is not None else "MCP process is not responding"
|
|
3137
|
+
record = self.records.get(server_id) or McpServiceRecord()
|
|
3138
|
+
client = record.client
|
|
3139
|
+
exit_code = client.process.poll() if client and client.process else None
|
|
3140
|
+
status, error = service_status(record, bool(client and client.running()), exit_code)
|
|
3141
|
+
running = status == "running"
|
|
2961
3142
|
pref = self.preferences.get(server_id, {})
|
|
2962
3143
|
result.append({
|
|
2963
3144
|
"id": server_id,
|
|
@@ -2966,13 +3147,13 @@ class McpServiceManager:
|
|
|
2966
3147
|
"help": server.get("help"),
|
|
2967
3148
|
"remote": server.get("remote"),
|
|
2968
3149
|
"enabled": pref.get("enabled", server.get("enabledByDefault", False)),
|
|
2969
|
-
"status":
|
|
3150
|
+
"status": status,
|
|
2970
3151
|
"toolCount": len(client.tools) if running else 0,
|
|
2971
3152
|
"error": error,
|
|
2972
3153
|
"options": server.get("options", []),
|
|
2973
3154
|
"userOptions": pref.get("options", {}),
|
|
2974
3155
|
"oauthUrl": client.oauth_url if client else None,
|
|
2975
|
-
"browser":
|
|
3156
|
+
"browser": record.browser if running else None,
|
|
2976
3157
|
})
|
|
2977
3158
|
|
|
2978
3159
|
return result
|
|
@@ -3021,7 +3202,7 @@ class McpServiceManager:
|
|
|
3021
3202
|
needs_install = True
|
|
3022
3203
|
|
|
3023
3204
|
if needs_install:
|
|
3024
|
-
self.
|
|
3205
|
+
self.records[server_id].phase = "installing"
|
|
3025
3206
|
self._trace(server_id, f"installing npm package: {package}@{version}")
|
|
3026
3207
|
manifest = service_dir / "package.json"
|
|
3027
3208
|
manifest.write_text(json.dumps({"private": True, "dependencies": {package: version}}, indent=2), encoding="utf-8")
|
|
@@ -3087,11 +3268,10 @@ class McpServiceManager:
|
|
|
3087
3268
|
server = self.services.get(server_id)
|
|
3088
3269
|
if not server:
|
|
3089
3270
|
raise KeyError(f"Servidor MCP desconocido: {server_id}")
|
|
3090
|
-
|
|
3091
|
-
if
|
|
3271
|
+
record = self.records.setdefault(server_id, McpServiceRecord())
|
|
3272
|
+
if record.client and record.client.running():
|
|
3092
3273
|
return self.list_servers()
|
|
3093
|
-
|
|
3094
|
-
self.errors.pop(server_id, None)
|
|
3274
|
+
record.phase, record.error = "starting", None
|
|
3095
3275
|
client = None
|
|
3096
3276
|
self._trace(server_id, "start requested")
|
|
3097
3277
|
try:
|
|
@@ -3114,9 +3294,9 @@ class McpServiceManager:
|
|
|
3114
3294
|
env = os.environ.copy()
|
|
3115
3295
|
for k, v in launch.get("env", {}).items():
|
|
3116
3296
|
env[k] = self._expand(v, values)
|
|
3117
|
-
|
|
3297
|
+
record.browser = None
|
|
3118
3298
|
if server_id in SERVICE_LAUNCH:
|
|
3119
|
-
extra_args, extra_env,
|
|
3299
|
+
extra_args, extra_env, record.browser = SERVICE_LAUNCH[server_id](browser)
|
|
3120
3300
|
args += extra_args
|
|
3121
3301
|
env.update(extra_env)
|
|
3122
3302
|
|
|
@@ -3126,38 +3306,29 @@ class McpServiceManager:
|
|
|
3126
3306
|
call_timeout=_positive_seconds(launch.get("callTimeoutSeconds"), DEFAULT_MCP_CALL_TIMEOUT_SECONDS)
|
|
3127
3307
|
)
|
|
3128
3308
|
# _prepare_service puede haber dejado el estado en "installing".
|
|
3129
|
-
|
|
3130
|
-
self.clients[server_id] = client
|
|
3309
|
+
record.phase, record.client = "starting", client
|
|
3131
3310
|
client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
|
|
3132
|
-
|
|
3311
|
+
record.phase = "running"
|
|
3133
3312
|
if remember:
|
|
3134
|
-
|
|
3135
|
-
entry["enabled"] = True
|
|
3136
|
-
self._save_preferences()
|
|
3313
|
+
self._remember(server_id, True)
|
|
3137
3314
|
self._trace(server_id, "started successfully")
|
|
3138
3315
|
except Exception as exc:
|
|
3139
|
-
|
|
3140
|
-
self.errors[server_id] = str(exc)
|
|
3316
|
+
record.phase, record.error = "error", str(exc)
|
|
3141
3317
|
self._trace(server_id, f"start failed: {exc}")
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
client.stop()
|
|
3318
|
+
stale, record.client = record.client or client, None
|
|
3319
|
+
if stale:
|
|
3320
|
+
stale.stop()
|
|
3146
3321
|
return self.list_servers()
|
|
3147
3322
|
|
|
3148
3323
|
def stop(self, server_id: str) -> list[dict]:
|
|
3149
3324
|
with self._lock:
|
|
3150
3325
|
self.services = self._load_services()
|
|
3151
|
-
|
|
3326
|
+
record = self.records.pop(server_id, McpServiceRecord())
|
|
3327
|
+
if server_id not in self.services and not record.client:
|
|
3152
3328
|
raise KeyError(f"Servidor MCP desconocido: {server_id}")
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
self.states[server_id] = "stopped"
|
|
3157
|
-
self.errors.pop(server_id, None)
|
|
3158
|
-
entry = self.preferences.setdefault(server_id, {})
|
|
3159
|
-
entry["enabled"] = False
|
|
3160
|
-
self._save_preferences()
|
|
3329
|
+
if record.client:
|
|
3330
|
+
record.client.stop()
|
|
3331
|
+
self._remember(server_id, False)
|
|
3161
3332
|
return self.list_servers()
|
|
3162
3333
|
|
|
3163
3334
|
def configure(self, server_id: str, options: dict | None = None) -> list[dict]:
|
|
@@ -3172,13 +3343,15 @@ class McpServiceManager:
|
|
|
3172
3343
|
self._save_preferences()
|
|
3173
3344
|
return self.list_servers()
|
|
3174
3345
|
|
|
3346
|
+
def _running_clients(self) -> list[tuple[str, StdioMcpClient]]:
|
|
3347
|
+
return [(server_id, record.client) for server_id, record in self.records.items()
|
|
3348
|
+
if record.client and record.client.running()]
|
|
3349
|
+
|
|
3175
3350
|
def tools(self) -> list[dict]:
|
|
3176
3351
|
aggregated = []
|
|
3177
3352
|
seen: set[str] = set()
|
|
3178
3353
|
with self._lock:
|
|
3179
|
-
for server_id, client in self.
|
|
3180
|
-
if not client.running():
|
|
3181
|
-
continue
|
|
3354
|
+
for server_id, client in self._running_clients():
|
|
3182
3355
|
for t in client.tools:
|
|
3183
3356
|
original = t.get("name") if isinstance(t, dict) else None
|
|
3184
3357
|
try:
|
|
@@ -3202,9 +3375,7 @@ class McpServiceManager:
|
|
|
3202
3375
|
def call(self, public_name: str, arguments: dict) -> dict:
|
|
3203
3376
|
target = None
|
|
3204
3377
|
with self._lock:
|
|
3205
|
-
for server_id, client in self.
|
|
3206
|
-
if not client.running():
|
|
3207
|
-
continue
|
|
3378
|
+
for server_id, client in self._running_clients():
|
|
3208
3379
|
for t in client.tools:
|
|
3209
3380
|
try:
|
|
3210
3381
|
matches = public_tool_name(server_id, t.get("name")) == public_name
|
|
@@ -3223,9 +3394,10 @@ class McpServiceManager:
|
|
|
3223
3394
|
|
|
3224
3395
|
def close(self):
|
|
3225
3396
|
with self._lock:
|
|
3226
|
-
for
|
|
3227
|
-
client
|
|
3228
|
-
|
|
3397
|
+
for record in self.records.values():
|
|
3398
|
+
if record.client:
|
|
3399
|
+
record.client.stop()
|
|
3400
|
+
record.phase, record.client, record.error = "stopped", None, None
|
|
3229
3401
|
|
|
3230
3402
|
|
|
3231
3403
|
GLOBAL_MCP_MANAGER = McpServiceManager()
|
|
@@ -3239,16 +3411,34 @@ _SERVER_SHUTTING_DOWN = threading.Event()
|
|
|
3239
3411
|
# Si el servidor se detiene al cerrar el navegador, cerrar la pestaña detiene también los comandos.
|
|
3240
3412
|
EXIT_ON_CLOSE = False
|
|
3241
3413
|
# zerochat/run/progress ya se registra con su propio texto en dispatch_run.
|
|
3242
|
-
QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list", "zerochat/run/progress"})
|
|
3414
|
+
QUIET_RPC_METHODS = frozenset({"zerochat/heartbeat", "zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list", "zerochat/run/progress"})
|
|
3243
3415
|
|
|
3244
3416
|
|
|
3245
3417
|
def mark_browser_active():
|
|
3246
|
-
"""Registra la presencia
|
|
3418
|
+
"""Registra la presencia del navegador ante cualquier petición de un origen permitido, aunque su
|
|
3419
|
+
token haya caducado: el auto-cierre no detiene el servidor ni sus comandos al cambiar el día."""
|
|
3247
3420
|
global HEARTBEAT_LAST_SEEN, HEARTBEAT_INITIALIZED
|
|
3248
3421
|
HEARTBEAT_LAST_SEEN = time.monotonic()
|
|
3249
3422
|
HEARTBEAT_INITIALIZED = True
|
|
3250
3423
|
|
|
3251
3424
|
|
|
3425
|
+
def live_state() -> dict:
|
|
3426
|
+
"""
|
|
3427
|
+
Estado del servidor que la interfaz sigue en vivo, en initialize y en cada zerochat/heartbeat:
|
|
3428
|
+
cwd, para notar que otra pestaña ha cambiado el directorio del proyecto; jobs, los comandos en
|
|
3429
|
+
marcha, para mostrarlos y avisar antes de cerrar si se detendrían; exit_on_close; playwright, si
|
|
3430
|
+
se puede navegar aunque se haya instalado o quitado durante la sesión; y servers, los servicios
|
|
3431
|
+
MCP externos aunque otra pestaña los cambie o su proceso muera.
|
|
3432
|
+
"""
|
|
3433
|
+
return {
|
|
3434
|
+
"cwd": str(Path.cwd().resolve()),
|
|
3435
|
+
"jobs": [job.summary() for job in JOBS.running()],
|
|
3436
|
+
"exit_on_close": EXIT_ON_CLOSE,
|
|
3437
|
+
"playwright": playwright_status(),
|
|
3438
|
+
"servers": GLOBAL_MCP_MANAGER.list_servers()
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
|
|
3252
3442
|
def stop_zerochat_server(server: ThreadingHTTPServer):
|
|
3253
3443
|
"""Detiene limpiamente el servidor y todos los subsistemas evitando reentradas."""
|
|
3254
3444
|
if _SERVER_SHUTTING_DOWN.is_set():
|
|
@@ -3535,24 +3725,20 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3535
3725
|
return
|
|
3536
3726
|
self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
|
|
3537
3727
|
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
3538
|
-
self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client, X-ZeroChat-Tool-Session, X-ZeroChat-Tool-Expires, X-ZeroChat-Tool-Nonce, X-ZeroChat-Tool-Signature")
|
|
3728
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client, X-ZeroChat-Proxy, X-ZeroChat-Tool-Session, X-ZeroChat-Tool-Expires, X-ZeroChat-Tool-Nonce, X-ZeroChat-Tool-Signature")
|
|
3539
3729
|
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
3540
3730
|
|
|
3541
3731
|
def verify_token(self) -> bool:
|
|
3542
|
-
"""
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
elif "X-ZeroChat-Token" in self.headers:
|
|
3548
|
-
token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
|
|
3549
|
-
|
|
3732
|
+
"""
|
|
3733
|
+
Comprueba el token diario vigente, solo en cabeceras HTTP: el de un día anterior se rechaza.
|
|
3734
|
+
X-ZeroChat-Token va antes que Authorization, que tras el proxy inverso lleva la clave del proxy.
|
|
3735
|
+
"""
|
|
3736
|
+
token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
|
|
3550
3737
|
if not token_candidate:
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
return is_valid
|
|
3738
|
+
auth_header = self.headers.get("Authorization", "")
|
|
3739
|
+
if auth_header.startswith("Bearer "):
|
|
3740
|
+
token_candidate = auth_header[7:].strip()
|
|
3741
|
+
return bool(token_candidate) and hmac.compare_digest(token_candidate.encode(), current_session_token().encode())
|
|
3556
3742
|
|
|
3557
3743
|
def verify_tool_authorization(self, body: bytes, request_path: str) -> str | None:
|
|
3558
3744
|
"""Validate and atomically consume an authorization for one tool call."""
|
|
@@ -3593,21 +3779,17 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3593
3779
|
def do_OPTIONS(self):
|
|
3594
3780
|
t0 = time.monotonic()
|
|
3595
3781
|
safe_path = sanitize_log_path(self.path)
|
|
3596
|
-
path_clean = self.path.split("?", 1)[0].rstrip("/")
|
|
3597
|
-
is_heartbeat = path_clean == "/zerochat/heartbeat"
|
|
3598
|
-
if not is_heartbeat:
|
|
3599
|
-
self._log_req("OPTIONS", safe_path)
|
|
3600
3782
|
origin = self.headers.get("Origin")
|
|
3601
3783
|
if not is_allowed_origin(origin, require_origin=True):
|
|
3784
|
+
self._log_req("OPTIONS", safe_path)
|
|
3602
3785
|
self.send_response(403)
|
|
3603
3786
|
self.end_headers()
|
|
3604
3787
|
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
3605
3788
|
return
|
|
3789
|
+
# La comprobación previa precede a cada POST con cabeceras propias, también al latido: no se registra.
|
|
3606
3790
|
self.send_response(204)
|
|
3607
3791
|
self.send_cors_headers()
|
|
3608
3792
|
self.end_headers()
|
|
3609
|
-
if not is_heartbeat:
|
|
3610
|
-
self._log_res(204, safe_path, (time.monotonic() - t0) * 1000)
|
|
3611
3793
|
|
|
3612
3794
|
def _send_json_response(self, status: int, data: dict) -> bool:
|
|
3613
3795
|
"""Envía JSON con cabeceras CORS; devuelve False si el cliente ya cerró la conexión."""
|
|
@@ -3625,15 +3807,14 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3625
3807
|
return False
|
|
3626
3808
|
|
|
3627
3809
|
def do_GET(self):
|
|
3810
|
+
"""Solo la interfaz servida en desarrollo y el favicon: la API va por POST."""
|
|
3628
3811
|
t0 = time.monotonic()
|
|
3629
3812
|
safe_path = sanitize_log_path(self.path)
|
|
3630
3813
|
path_clean = self.path.split("?", 1)[0].rstrip("/")
|
|
3631
|
-
|
|
3814
|
+
self._log_req("GET", safe_path)
|
|
3632
3815
|
|
|
3633
3816
|
origin = self.headers.get("Origin")
|
|
3634
3817
|
if not is_allowed_origin(origin):
|
|
3635
|
-
if not is_heartbeat:
|
|
3636
|
-
self._log_req("GET", safe_path)
|
|
3637
3818
|
self.send_response(403)
|
|
3638
3819
|
self.end_headers()
|
|
3639
3820
|
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
@@ -3641,7 +3822,6 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3641
3822
|
|
|
3642
3823
|
# Servir archivos estáticos del repositorio si estamos en entorno de desarrollo
|
|
3643
3824
|
if path_clean and self.serve_static_file(path_clean):
|
|
3644
|
-
self._log_req("GET", safe_path)
|
|
3645
3825
|
self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
|
|
3646
3826
|
return
|
|
3647
3827
|
|
|
@@ -3652,52 +3832,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3652
3832
|
self.end_headers()
|
|
3653
3833
|
return
|
|
3654
3834
|
|
|
3655
|
-
|
|
3656
|
-
self._log_req("GET", safe_path)
|
|
3657
|
-
|
|
3658
|
-
if not self.verify_token():
|
|
3659
|
-
self._reject(401, {"error": "Unauthorized: invalid or missing session token"}, safe_path, t0, "Token de sesión ausente o inválido")
|
|
3660
|
-
return
|
|
3661
|
-
|
|
3662
|
-
if is_heartbeat:
|
|
3663
|
-
mark_browser_active()
|
|
3664
|
-
# cwd permite a cada pestaña notar que otra ha cambiado el directorio del proyecto;
|
|
3665
|
-
# jobs, mostrar los comandos que siguen en marcha y avisar antes de cerrar si se detendrían;
|
|
3666
|
-
# playwright, si se puede navegar aunque se haya instalado o quitado durante la sesión;
|
|
3667
|
-
# servers, los servicios MCP externos aunque otra pestaña los cambie o su proceso muera.
|
|
3668
|
-
self._send_json_response(200, {
|
|
3669
|
-
"ok": True,
|
|
3670
|
-
"cwd": str(Path.cwd().resolve()),
|
|
3671
|
-
"jobs": [job.summary() for job in JOBS.running()],
|
|
3672
|
-
"exit_on_close": EXIT_ON_CLOSE,
|
|
3673
|
-
"playwright": playwright_status(),
|
|
3674
|
-
"servers": GLOBAL_MCP_MANAGER.list_servers()
|
|
3675
|
-
})
|
|
3676
|
-
return
|
|
3677
|
-
|
|
3678
|
-
accept = self.headers.get("Accept", "")
|
|
3679
|
-
if "/sse" in self.path or "text/event-stream" in accept:
|
|
3680
|
-
self.send_response(200)
|
|
3681
|
-
self.send_header("Content-Type", "text/event-stream")
|
|
3682
|
-
self.send_header("Cache-Control", "no-store")
|
|
3683
|
-
self.send_header("Connection", "keep-alive")
|
|
3684
|
-
self.send_cors_headers()
|
|
3685
|
-
self.end_headers()
|
|
3686
|
-
endpoint_data = b"/mcp/external" if "/mcp/external" in self.path else b"/"
|
|
3687
|
-
self.wfile.write(b"event: endpoint\r\ndata: " + endpoint_data + b"\r\n\r\n")
|
|
3688
|
-
self.wfile.flush()
|
|
3689
|
-
self._log_res(200, f"{safe_path} [SSE canal activo]", (time.monotonic() - t0) * 1000)
|
|
3690
|
-
return
|
|
3691
|
-
|
|
3692
|
-
self._send_json_response(200, {
|
|
3693
|
-
"status": "active",
|
|
3694
|
-
"server": "ZeroChat Local Server",
|
|
3695
|
-
"version": VERSION,
|
|
3696
|
-
"cwd": str(Path.cwd().resolve()),
|
|
3697
|
-
"tools_count": len(LOCAL_TOOLS_DEFINITIONS),
|
|
3698
|
-
"os": DETECTED_OS
|
|
3699
|
-
})
|
|
3700
|
-
self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
|
|
3835
|
+
self._reject(404, {"error": "Not found"}, safe_path, t0, "Ruta GET inexistente")
|
|
3701
3836
|
|
|
3702
3837
|
def _reject(self, status: int, data: dict, safe_path: str, t0: float, log_message: str):
|
|
3703
3838
|
self._send_json_response(status, data)
|
|
@@ -3826,7 +3961,11 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3826
3961
|
"cwd": str(Path.cwd().resolve()),
|
|
3827
3962
|
"os": DETECTED_OS
|
|
3828
3963
|
}
|
|
3829
|
-
return self._initialize_result(server_info), None, ""
|
|
3964
|
+
return {**self._initialize_result(server_info), "liveState": live_state()}, None, ""
|
|
3965
|
+
if method == "zerochat/heartbeat":
|
|
3966
|
+
return live_state(), None, ""
|
|
3967
|
+
if method == "zerochat/login":
|
|
3968
|
+
return self._login(params)
|
|
3830
3969
|
if method == "tools/list":
|
|
3831
3970
|
return {"tools": [dict(definition) for definition in LOCAL_TOOLS_DEFINITIONS]}, None, ""
|
|
3832
3971
|
if method == "tools/call":
|
|
@@ -3888,6 +4027,49 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3888
4027
|
return self._dispatch_browser(method, params)
|
|
3889
4028
|
return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
|
|
3890
4029
|
|
|
4030
|
+
@staticmethod
|
|
4031
|
+
def _login(params: dict) -> tuple[dict | None, dict | None, str]:
|
|
4032
|
+
try:
|
|
4033
|
+
return login(params), None, ""
|
|
4034
|
+
except LoginError as exc:
|
|
4035
|
+
error = {"code": exc.code, "message": str(exc)}
|
|
4036
|
+
if exc.data:
|
|
4037
|
+
error["data"] = exc.data
|
|
4038
|
+
return None, error, ""
|
|
4039
|
+
except (RuntimeError, OSError) as exc:
|
|
4040
|
+
return None, {"code": -32603, "message": f"No se pudo comprobar la contraseña: {exc}"}, str(exc)
|
|
4041
|
+
|
|
4042
|
+
def _serve_login(self, safe_path: str, t0: float) -> bool:
|
|
4043
|
+
"""Sin token solo se atiende zerochat/login, con un cuerpo pequeño; devuelve False si la petición es otra."""
|
|
4044
|
+
try:
|
|
4045
|
+
content_len = int(self.headers.get("Content-Length"))
|
|
4046
|
+
except (TypeError, ValueError):
|
|
4047
|
+
return False
|
|
4048
|
+
if not 0 <= content_len <= LOGIN_MAX_BODY_BYTES:
|
|
4049
|
+
return False
|
|
4050
|
+
try:
|
|
4051
|
+
req = json.loads(self.rfile.read(content_len).decode("utf-8"))
|
|
4052
|
+
except ValueError:
|
|
4053
|
+
return False
|
|
4054
|
+
if self._validate_rpc(req) or req["method"] != "zerochat/login":
|
|
4055
|
+
return False
|
|
4056
|
+
log_detail = f"{safe_path} [rpc: zerochat/login]"
|
|
4057
|
+
self._log_req("POST", log_detail)
|
|
4058
|
+
self._send_rpc_response(req.get("id"), self._login(req.get("params", {})), log_detail, t0)
|
|
4059
|
+
return True
|
|
4060
|
+
|
|
4061
|
+
def _send_rpc_response(self, req_id, outcome: tuple[dict | None, dict | None, str], log_detail: str, t0: float, quiet: bool = False):
|
|
4062
|
+
result, error, tool_error_info = outcome
|
|
4063
|
+
response_payload = {"jsonrpc": "2.0", "id": req_id}
|
|
4064
|
+
if error:
|
|
4065
|
+
response_payload["error"] = error
|
|
4066
|
+
error_info = f"[{error.get('code')}] {error.get('message')}"
|
|
4067
|
+
else:
|
|
4068
|
+
response_payload["result"] = result
|
|
4069
|
+
error_info = tool_error_info
|
|
4070
|
+
if self._send_json_response(200, response_payload) and not quiet:
|
|
4071
|
+
self._log_res(200, log_detail, (time.monotonic() - t0) * 1000, error_info=error_info)
|
|
4072
|
+
|
|
3891
4073
|
@staticmethod
|
|
3892
4074
|
def _dispatch_jobs(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
|
|
3893
4075
|
"""Control de los comandos desde la interfaz: tarjetas, log y botón de detener."""
|
|
@@ -3980,7 +4162,11 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3980
4162
|
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
3981
4163
|
return
|
|
3982
4164
|
|
|
4165
|
+
mark_browser_active()
|
|
3983
4166
|
if not self.verify_token():
|
|
4167
|
+
# Sin token válido solo cabe identificarse para obtenerlo.
|
|
4168
|
+
if self._serve_login(safe_path, t0):
|
|
4169
|
+
return
|
|
3984
4170
|
self._log_req("POST", safe_path)
|
|
3985
4171
|
self._reject(401, {
|
|
3986
4172
|
"jsonrpc": "2.0",
|
|
@@ -4022,23 +4208,12 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
4022
4208
|
|
|
4023
4209
|
try:
|
|
4024
4210
|
if "/mcp/external" in req_path:
|
|
4025
|
-
|
|
4211
|
+
outcome = self._dispatch_external(method, params)
|
|
4026
4212
|
else:
|
|
4027
|
-
|
|
4213
|
+
outcome = self._dispatch_local(method, params)
|
|
4028
4214
|
except Exception as ex:
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
response_payload = {"jsonrpc": "2.0", "id": req_id}
|
|
4033
|
-
if error:
|
|
4034
|
-
response_payload["error"] = error
|
|
4035
|
-
error_info = f"[{error.get('code')}] {error.get('message')}"
|
|
4036
|
-
else:
|
|
4037
|
-
response_payload["result"] = result
|
|
4038
|
-
error_info = tool_error_info
|
|
4039
|
-
|
|
4040
|
-
if self._send_json_response(200, response_payload) and not quiet:
|
|
4041
|
-
self._log_res(200, log_detail, (time.monotonic() - t0) * 1000, error_info=error_info)
|
|
4215
|
+
outcome = None, {"code": -32603, "message": f"Error interno: {ex}"}, ""
|
|
4216
|
+
self._send_rpc_response(req_id, outcome, log_detail, t0, quiet)
|
|
4042
4217
|
|
|
4043
4218
|
def log_message(self, format, *args):
|
|
4044
4219
|
# Silenciar logs ruidosos por defecto
|
|
@@ -4453,10 +4628,8 @@ class ZeroChatArgumentParser(argparse.ArgumentParser):
|
|
|
4453
4628
|
self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
|
|
4454
4629
|
|
|
4455
4630
|
|
|
4456
|
-
def
|
|
4457
|
-
|
|
4458
|
-
global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
|
|
4459
|
-
|
|
4631
|
+
def build_parser() -> ZeroChatArgumentParser:
|
|
4632
|
+
"""Opciones de la línea de órdenes."""
|
|
4460
4633
|
parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
|
|
4461
4634
|
# Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
|
|
4462
4635
|
parser.color = False
|
|
@@ -4475,87 +4648,91 @@ def main():
|
|
|
4475
4648
|
parser.add_argument("--allow-dangerous-tools", action="store_true", help="Con --prompt, aprobar también las herramientas que escriben o ejecutan (write_file, edit_file, run_command); las demás se aprueban siempre para la sesión")
|
|
4476
4649
|
parser.add_argument("--headless", action="store_true", help="Con --prompt, abrir el navegador predeterminado sin ventana (si ya está abierto puede verse la pestaña)")
|
|
4477
4650
|
parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
|
|
4478
|
-
|
|
4651
|
+
return parser
|
|
4479
4652
|
|
|
4480
|
-
# Los avisos no persisten entre ejecuciones del servidor.
|
|
4481
|
-
reset_notices()
|
|
4482
4653
|
|
|
4483
|
-
|
|
4654
|
+
def run_session_from_args(parser: ZeroChatArgumentParser, args) -> RunSession | None:
|
|
4655
|
+
"""Valida las combinaciones de opciones y devuelve la sesión de --prompt, si la hay."""
|
|
4656
|
+
if args.prompt is None:
|
|
4657
|
+
if args.headless or args.keep_page or args.allow_dangerous_tools or args.profile is not None or args.export is not None:
|
|
4658
|
+
parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
|
|
4659
|
+
return None
|
|
4660
|
+
if not args.prompt.strip():
|
|
4661
|
+
parser.error("el prompt está vacío")
|
|
4662
|
+
if args.no_browser:
|
|
4663
|
+
parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
|
|
4664
|
+
if args.keep_server:
|
|
4665
|
+
parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
|
|
4666
|
+
if args.profile is not None and not args.profile.strip():
|
|
4667
|
+
parser.error("--profile no puede estar vacío")
|
|
4668
|
+
if args.keep_page and args.headless:
|
|
4669
|
+
parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
|
|
4670
|
+
export_path = None
|
|
4671
|
+
if args.export is not None:
|
|
4672
|
+
export_path = Path(args.export).expanduser().absolute()
|
|
4673
|
+
if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
|
|
4674
|
+
parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
|
|
4675
|
+
if not export_path.parent.is_dir():
|
|
4676
|
+
parser.error(f"--export: no existe el directorio {export_path.parent}")
|
|
4677
|
+
return RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
|
|
4678
|
+
|
|
4679
|
+
|
|
4680
|
+
def redirect_log(args):
|
|
4681
|
+
"""Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
|
|
4682
|
+
y con --log off se descarta. Devuelve el stdout original, el de la respuesta."""
|
|
4484
4683
|
result_out = sys.stdout
|
|
4485
|
-
if args.prompt is None and (args.headless or args.keep_page or args.allow_dangerous_tools or args.profile is not None or args.export is not None):
|
|
4486
|
-
parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
|
|
4487
|
-
if args.prompt is not None:
|
|
4488
|
-
if not args.prompt.strip():
|
|
4489
|
-
parser.error("el prompt está vacío")
|
|
4490
|
-
if args.no_browser:
|
|
4491
|
-
parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
|
|
4492
|
-
if args.keep_server:
|
|
4493
|
-
parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
|
|
4494
|
-
if args.profile is not None and not args.profile.strip():
|
|
4495
|
-
parser.error("--profile no puede estar vacío")
|
|
4496
|
-
if args.keep_page and args.headless:
|
|
4497
|
-
parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
|
|
4498
|
-
export_path = None
|
|
4499
|
-
if args.export is not None:
|
|
4500
|
-
export_path = Path(args.export).expanduser().absolute()
|
|
4501
|
-
if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
|
|
4502
|
-
parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
|
|
4503
|
-
if not export_path.parent.is_dir():
|
|
4504
|
-
parser.error(f"--export: no existe el directorio {export_path.parent}")
|
|
4505
|
-
RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
|
|
4506
|
-
# Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
|
|
4507
|
-
# y con --log off se descarta.
|
|
4508
4684
|
log_enabled = args.log == "on" if args.log else RUN_SESSION is None
|
|
4509
4685
|
if not log_enabled:
|
|
4510
4686
|
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
|
4511
4687
|
elif RUN_SESSION:
|
|
4512
4688
|
sys.stdout = sys.stderr
|
|
4689
|
+
return result_out
|
|
4513
4690
|
|
|
4514
|
-
if args.test:
|
|
4515
|
-
print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
|
|
4516
|
-
print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
|
|
4517
|
-
print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
|
|
4518
|
-
print(timestamp_message("TEST all local tools ready."))
|
|
4519
|
-
return
|
|
4520
4691
|
|
|
4692
|
+
def run_self_test():
|
|
4693
|
+
"""--test: comprueba las herramientas locales básicas sin arrancar el servidor."""
|
|
4694
|
+
print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
|
|
4695
|
+
print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
|
|
4696
|
+
print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
|
|
4697
|
+
print(timestamp_message("TEST all local tools ready."))
|
|
4698
|
+
|
|
4699
|
+
|
|
4700
|
+
def prepare_environment(args, is_dev: bool):
|
|
4701
|
+
"""Limpia restos de una sesión anterior, prepara el entorno MCP y lanza las comprobaciones de fondo."""
|
|
4521
4702
|
orphans = cleanup_orphan_jobs()
|
|
4522
4703
|
if orphans:
|
|
4523
4704
|
print(timestamp_message(f"Detenidos {orphans} comando(s) que seguían en marcha de una sesión anterior interrumpida."))
|
|
4524
|
-
|
|
4525
|
-
# 1. Asegurar el entorno MCP aislado en ambos modos de distribución.
|
|
4705
|
+
# Entorno MCP aislado en ambos modos de distribución.
|
|
4526
4706
|
if not args.no_venv:
|
|
4527
4707
|
ensure_virtual_environment()
|
|
4528
|
-
|
|
4529
|
-
browser_availability = browser_action_availability()
|
|
4530
|
-
if not browser_availability.get("available"):
|
|
4708
|
+
if not browser_action_availability().get("available"):
|
|
4531
4709
|
add_notice(
|
|
4532
4710
|
"browser_action desactivada: para usarla, inicia el servicio 'playwright' en Ajustes → MCP "
|
|
4533
4711
|
"(se instala solo). Requiere Node.js y disponer de Chrome o Edge en el sistema."
|
|
4534
4712
|
)
|
|
4535
|
-
|
|
4536
|
-
# 2. Detectar entorno de desarrollo y resolver URL de destino
|
|
4537
|
-
dev_root = get_dev_root()
|
|
4538
|
-
static_root = get_static_root()
|
|
4539
|
-
is_dev = dev_root is not None
|
|
4540
|
-
is_installed = is_installed_runtime()
|
|
4541
|
-
|
|
4542
|
-
# 3. Comprobar versión remota en segundo plano (solo fuera del entorno de desarrollo local)
|
|
4713
|
+
# Versión remota en segundo plano, solo fuera del entorno de desarrollo local.
|
|
4543
4714
|
if not is_dev:
|
|
4544
4715
|
threading.Thread(target=check_version, daemon=True).start()
|
|
4545
4716
|
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4717
|
+
|
|
4718
|
+
def reset_session_credentials():
|
|
4719
|
+
"""Carga el token diario y renueva la clave de firma de herramientas."""
|
|
4720
|
+
global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
|
|
4721
|
+
current_session_token()
|
|
4549
4722
|
# A restart deliberately invalidates every browser-side signing credential.
|
|
4550
4723
|
TOOL_AUTH_KEY = secrets.token_bytes(32)
|
|
4551
4724
|
TOOL_AUTH_SESSION_ID = secrets.token_urlsafe(18)
|
|
4552
4725
|
with TOOL_AUTH_NONCES_LOCK:
|
|
4553
4726
|
TOOL_AUTH_NONCES = {}
|
|
4554
4727
|
|
|
4555
|
-
|
|
4556
|
-
|
|
4728
|
+
|
|
4729
|
+
def open_server(args, local_ui: bool) -> ThreadingHTTPServer:
|
|
4730
|
+
"""Abre el puerto de escucha; con --prompt, si el puerto por defecto está ocupado, usa uno libre."""
|
|
4731
|
+
global ACTIVE_PORT, ACTIVE_HOST
|
|
4732
|
+
ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
|
|
4733
|
+
ACTIVE_HOST = args.host
|
|
4557
4734
|
try:
|
|
4558
|
-
|
|
4735
|
+
return ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
|
|
4559
4736
|
except OSError as exc:
|
|
4560
4737
|
if not RUN_SESSION:
|
|
4561
4738
|
raise
|
|
@@ -4563,46 +4740,49 @@ def main():
|
|
|
4563
4740
|
hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
|
|
4564
4741
|
run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
|
|
4565
4742
|
sys.exit(RUN_EXIT_SETUP)
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
if exposure_warning:
|
|
4576
|
-
add_notice(exposure_warning)
|
|
4743
|
+
# Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
|
|
4744
|
+
try:
|
|
4745
|
+
server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
|
|
4746
|
+
except OSError as retry_exc:
|
|
4747
|
+
run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
|
|
4748
|
+
sys.exit(RUN_EXIT_SETUP)
|
|
4749
|
+
log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
|
|
4750
|
+
ACTIVE_PORT = server.server_address[1]
|
|
4751
|
+
return server
|
|
4577
4752
|
|
|
4753
|
+
|
|
4754
|
+
def interface_url(args, static_root) -> str:
|
|
4755
|
+
"""URL de la interfaz: la indicada, la local de desarrollo o la de GitHub Pages."""
|
|
4578
4756
|
if args.ui_url:
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4757
|
+
return args.ui_url
|
|
4758
|
+
if static_root:
|
|
4759
|
+
return f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
|
|
4760
|
+
return DEFAULT_UI_URL
|
|
4761
|
+
|
|
4584
4762
|
|
|
4763
|
+
def launch_url(ui_url: str) -> str:
|
|
4764
|
+
"""URL con que zerochat abre el navegador: el token vigente y el servidor van en el fragmento."""
|
|
4585
4765
|
browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
|
|
4586
|
-
fragment = {'token':
|
|
4766
|
+
fragment = {'token': current_session_token(), 'host': browser_host, 'port': ACTIVE_PORT}
|
|
4587
4767
|
if RUN_SESSION:
|
|
4588
4768
|
fragment['run'] = RUN_SESSION.run_id
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
exit_on_close = not args.keep_server
|
|
4592
|
-
EXIT_ON_CLOSE = exit_on_close
|
|
4769
|
+
return f"{ui_url}#{urlencode(fragment)}"
|
|
4770
|
+
|
|
4593
4771
|
|
|
4772
|
+
def print_header(dev_root, ui_url: str, exposure_warning: str | None, exit_on_close: bool):
|
|
4773
|
+
"""Cabecera del servidor con su configuración (sin el token)."""
|
|
4594
4774
|
print("=" * 64)
|
|
4595
4775
|
print(f" ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
|
|
4596
4776
|
print(f" Directorio de trabajo : {Path.cwd()}")
|
|
4597
4777
|
print(f" Datos y MCP : {get_data_dir()}")
|
|
4598
4778
|
print(f" Entorno MCP : {get_venv_dir()}")
|
|
4599
|
-
if
|
|
4779
|
+
if dev_root is not None:
|
|
4600
4780
|
print(f" Modo de ejecución : Desarrollo local ({dev_root})")
|
|
4601
|
-
elif
|
|
4781
|
+
elif is_installed_runtime():
|
|
4602
4782
|
print(" Modo de ejecución : Paquete PyPI (GitHub Pages)")
|
|
4603
4783
|
else:
|
|
4604
4784
|
print(" Modo de ejecución : Producción (Web universal)")
|
|
4605
|
-
print(f" Servidor HTTP
|
|
4785
|
+
print(f" Servidor HTTP : http://{ACTIVE_HOST}:{ACTIVE_PORT}")
|
|
4606
4786
|
if exposure_warning:
|
|
4607
4787
|
print(f" AVISO : {exposure_warning}")
|
|
4608
4788
|
print(" Token de sesión (diario): configurado")
|
|
@@ -4613,31 +4793,41 @@ def main():
|
|
|
4613
4793
|
print(" Auto-cierre : Desactivado")
|
|
4614
4794
|
print("=" * 64, flush=True)
|
|
4615
4795
|
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
sys.exit(RUN_EXIT_SETUP)
|
|
4624
|
-
if headless_browser is not None:
|
|
4625
|
-
threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
|
|
4626
|
-
threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
|
|
4627
|
-
else:
|
|
4628
|
-
CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
|
|
4796
|
+
|
|
4797
|
+
def start_interface(args, parser, server: ThreadingHTTPServer, ui_url: str, result_out):
|
|
4798
|
+
"""Abre la interfaz: en modo ejecución, con su supervisor; si no, con el control de consola.
|
|
4799
|
+
Devuelve el navegador sin ventana de --headless, si se lanzó."""
|
|
4800
|
+
global CONSOLE_CONTROL
|
|
4801
|
+
if not RUN_SESSION:
|
|
4802
|
+
CONSOLE_CONTROL = ConsoleControl(server, parser, launch_url=lambda: launch_url(ui_url))
|
|
4629
4803
|
CONSOLE_CONTROL.start()
|
|
4630
4804
|
if not args.no_browser:
|
|
4631
|
-
launch_browser(
|
|
4805
|
+
launch_browser(launch_url(ui_url))
|
|
4806
|
+
return None
|
|
4807
|
+
target_url = launch_url(ui_url)
|
|
4808
|
+
headless_browser = launch_headless_browser(target_url) if args.headless else None
|
|
4809
|
+
if headless_browser is None and not launch_browser(target_url):
|
|
4810
|
+
run_error("No se pudo abrir el navegador.")
|
|
4811
|
+
server.server_close()
|
|
4812
|
+
sys.stdout = result_out
|
|
4813
|
+
sys.exit(RUN_EXIT_SETUP)
|
|
4814
|
+
if headless_browser is not None:
|
|
4815
|
+
threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
|
|
4816
|
+
threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
|
|
4817
|
+
return headless_browser
|
|
4818
|
+
|
|
4819
|
+
|
|
4820
|
+
def start_heartbeat_watchdog(server: ThreadingHTTPServer, require_initial: bool):
|
|
4821
|
+
"""Detiene el servidor cuando el navegador deja de enviar latidos."""
|
|
4822
|
+
threading.Thread(
|
|
4823
|
+
target=heartbeat_watchdog,
|
|
4824
|
+
args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
|
|
4825
|
+
daemon=True
|
|
4826
|
+
).start()
|
|
4632
4827
|
|
|
4633
|
-
if exit_on_close:
|
|
4634
|
-
require_initial = not args.no_browser
|
|
4635
|
-
threading.Thread(
|
|
4636
|
-
target=heartbeat_watchdog,
|
|
4637
|
-
args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
|
|
4638
|
-
daemon=True
|
|
4639
|
-
).start()
|
|
4640
4828
|
|
|
4829
|
+
def install_signal_handlers(server: ThreadingHTTPServer):
|
|
4830
|
+
"""Ctrl+C, SIGTERM y el cierre del terminal detienen el servidor de forma ordenada."""
|
|
4641
4831
|
def shutdown(*_):
|
|
4642
4832
|
console_log("\n" + timestamp_message("Deteniendo servidor ZeroChat..."))
|
|
4643
4833
|
stop_zerochat_server(server)
|
|
@@ -4648,6 +4838,10 @@ def main():
|
|
|
4648
4838
|
if hasattr(signal, "SIGHUP"):
|
|
4649
4839
|
signal.signal(signal.SIGHUP, shutdown)
|
|
4650
4840
|
|
|
4841
|
+
|
|
4842
|
+
def serve(server: ThreadingHTTPServer):
|
|
4843
|
+
"""Atiende peticiones hasta la parada y libera después el servidor y la consola."""
|
|
4844
|
+
global CONSOLE_CONTROL
|
|
4651
4845
|
try:
|
|
4652
4846
|
server.serve_forever()
|
|
4653
4847
|
finally:
|
|
@@ -4657,16 +4851,58 @@ def main():
|
|
|
4657
4851
|
CONSOLE_CONTROL.close()
|
|
4658
4852
|
CONSOLE_CONTROL = None
|
|
4659
4853
|
|
|
4854
|
+
|
|
4855
|
+
def finish_run(headless_browser, result_out):
|
|
4856
|
+
"""Modo ejecución: escribe la respuesta final en el stdout original y sale con su código."""
|
|
4857
|
+
# Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
|
|
4858
|
+
if headless_browser is not None and headless_browser.poll() is None:
|
|
4859
|
+
_kill_process_tree(headless_browser)
|
|
4860
|
+
code, text = run_outcome(RUN_SESSION)
|
|
4861
|
+
sys.stdout = result_out
|
|
4862
|
+
if text is not None:
|
|
4863
|
+
result_out.write(text if text.endswith("\n") else text + "\n")
|
|
4864
|
+
result_out.flush()
|
|
4865
|
+
sys.exit(code)
|
|
4866
|
+
|
|
4867
|
+
|
|
4868
|
+
def main():
|
|
4869
|
+
global RUN_SESSION, EXIT_ON_CLOSE
|
|
4870
|
+
|
|
4871
|
+
parser = build_parser()
|
|
4872
|
+
args = parser.parse_args()
|
|
4873
|
+
# Los avisos no persisten entre ejecuciones del servidor.
|
|
4874
|
+
reset_notices()
|
|
4875
|
+
RUN_SESSION = run_session_from_args(parser, args)
|
|
4876
|
+
result_out = redirect_log(args)
|
|
4877
|
+
|
|
4878
|
+
if args.test:
|
|
4879
|
+
run_self_test()
|
|
4880
|
+
return
|
|
4881
|
+
|
|
4882
|
+
dev_root = get_dev_root()
|
|
4883
|
+
static_root = get_static_root()
|
|
4884
|
+
prepare_environment(args, is_dev=dev_root is not None)
|
|
4885
|
+
reset_session_credentials()
|
|
4886
|
+
|
|
4887
|
+
# La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
|
|
4888
|
+
server = open_server(args, local_ui=static_root is not None and not args.ui_url)
|
|
4889
|
+
exposure_warning = remote_exposure_warning(ACTIVE_HOST)
|
|
4890
|
+
if exposure_warning:
|
|
4891
|
+
add_notice(exposure_warning)
|
|
4892
|
+
|
|
4893
|
+
ui_url = interface_url(args, static_root)
|
|
4894
|
+
# El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
|
|
4895
|
+
EXIT_ON_CLOSE = not args.keep_server
|
|
4896
|
+
print_header(dev_root, ui_url, exposure_warning, EXIT_ON_CLOSE)
|
|
4897
|
+
|
|
4898
|
+
headless_browser = start_interface(args, parser, server, ui_url, result_out)
|
|
4899
|
+
if EXIT_ON_CLOSE:
|
|
4900
|
+
start_heartbeat_watchdog(server, require_initial=not args.no_browser)
|
|
4901
|
+
install_signal_handlers(server)
|
|
4902
|
+
serve(server)
|
|
4903
|
+
|
|
4660
4904
|
if RUN_SESSION:
|
|
4661
|
-
|
|
4662
|
-
if headless_browser is not None and headless_browser.poll() is None:
|
|
4663
|
-
_kill_process_tree(headless_browser)
|
|
4664
|
-
code, text = run_outcome(RUN_SESSION)
|
|
4665
|
-
sys.stdout = result_out
|
|
4666
|
-
if text is not None:
|
|
4667
|
-
result_out.write(text if text.endswith("\n") else text + "\n")
|
|
4668
|
-
result_out.flush()
|
|
4669
|
-
sys.exit(code)
|
|
4905
|
+
finish_run(headless_browser, result_out)
|
|
4670
4906
|
|
|
4671
4907
|
|
|
4672
4908
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|