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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.9.0
3
+ Version: 9.11.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zerochat"
7
- version = "9.9.0"
7
+ version = "9.11.0"
8
8
  description = "Private local web client for AI models, tools and MCP services"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.9.0
3
+ Version: 9.11.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -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 y Server-Sent Events (SSE) con autenticación estricta por token efímero.
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.9.0"
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, target_url: str | None = None):
112
+ def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser, launch_url=None):
108
113
  self.server = server
109
114
  self.parser = parser
110
- self.target_url = target_url
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.target_url:
180
- launch_browser(self.target_url)
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 o genera al arrancar el servidor.
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 = 30_000
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.clients: dict[str, StdioMcpClient] = {}
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
- client = self.clients.get(server_id)
2953
- state = self.states.get(server_id, "stopped")
2954
- running = bool(client and client.running() and state == "running")
2955
- error = self.errors.get(server_id)
2956
- # El proceso de un servicio en marcha puede morir sin que nadie lo detenga.
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": "running" if running else state,
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": self.browsers.get(server_id) if running else None,
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.states[server_id] = "installing"
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
- current = self.clients.get(server_id)
3091
- if current and current.running():
3271
+ record = self.records.setdefault(server_id, McpServiceRecord())
3272
+ if record.client and record.client.running():
3092
3273
  return self.list_servers()
3093
- self.states[server_id] = "starting"
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
- self.browsers.pop(server_id, None)
3297
+ record.browser = None
3118
3298
  if server_id in SERVICE_LAUNCH:
3119
- extra_args, extra_env, self.browsers[server_id] = SERVICE_LAUNCH[server_id](browser)
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
- self.states[server_id] = "starting"
3130
- self.clients[server_id] = client
3309
+ record.phase, record.client = "starting", client
3131
3310
  client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
3132
- self.states[server_id] = "running"
3311
+ record.phase = "running"
3133
3312
  if remember:
3134
- entry = self.preferences.setdefault(server_id, {})
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
- self.states[server_id] = "error"
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
- if server_id in self.clients:
3143
- self.clients.pop(server_id).stop()
3144
- elif client:
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
- if server_id not in self.services and server_id not in self.clients:
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
- client = self.clients.pop(server_id, None)
3154
- if client:
3155
- client.stop()
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.clients.items():
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.clients.items():
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 client in list(self.clients.values()):
3227
- client.stop()
3228
- self.clients.clear()
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 activa del navegador ante cualquier petición válida."""
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
- """Comprueba el token de sesión exclusivamente en cabeceras HTTP."""
3543
- auth_header = self.headers.get("Authorization", "")
3544
- token_candidate = None
3545
- if auth_header.startswith("Bearer "):
3546
- token_candidate = auth_header[7:].strip()
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
- return False
3552
- is_valid = hmac.compare_digest(token_candidate, SESSION_TOKEN)
3553
- if is_valid:
3554
- mark_browser_active()
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
- is_heartbeat = path_clean == "/zerochat/heartbeat"
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
- if not is_heartbeat:
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
- result, error, tool_error_info = self._dispatch_external(method, params)
4211
+ outcome = self._dispatch_external(method, params)
4026
4212
  else:
4027
- result, error, tool_error_info = self._dispatch_local(method, params)
4213
+ outcome = self._dispatch_local(method, params)
4028
4214
  except Exception as ex:
4029
- result, tool_error_info = None, ""
4030
- error = {"code": -32603, "message": f"Error interno: {ex}"}
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 main():
4457
- global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL, EXIT_ON_CLOSE
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
- args = parser.parse_args()
4651
+ return parser
4479
4652
 
4480
- # Los avisos no persisten entre ejecuciones del servidor.
4481
- reset_notices()
4482
4653
 
4483
- # Modo ejecución: stdout queda solo para la respuesta final; todo el registro va a stderr.
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
- ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
4547
- ACTIVE_HOST = args.host
4548
- SESSION_TOKEN = get_daily_token()
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
- # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4556
- local_ui = static_root is not None and not args.ui_url
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
- server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
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
- # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4567
- try:
4568
- server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4569
- except OSError as retry_exc:
4570
- run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4571
- sys.exit(RUN_EXIT_SETUP)
4572
- log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4573
- ACTIVE_PORT = server.server_address[1]
4574
- exposure_warning = remote_exposure_warning(ACTIVE_HOST)
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
- ui_url = args.ui_url
4580
- elif static_root:
4581
- ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
4582
- else:
4583
- ui_url = DEFAULT_UI_URL
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': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
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
- target_url = f"{ui_url}#{urlencode(fragment)}"
4590
- # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
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 is_dev:
4779
+ if dev_root is not None:
4600
4780
  print(f" Modo de ejecución : Desarrollo local ({dev_root})")
4601
- elif is_installed:
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/SSE : http://{ACTIVE_HOST}:{ACTIVE_PORT}")
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
- headless_browser = None
4617
- if RUN_SESSION:
4618
- headless_browser = launch_headless_browser(target_url) if args.headless else None
4619
- if headless_browser is None and not launch_browser(target_url):
4620
- run_error("No se pudo abrir el navegador.")
4621
- server.server_close()
4622
- sys.stdout = result_out
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(target_url)
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
- # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
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