zerochat 9.4.0__tar.gz → 9.6.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.4.0
3
+ Version: 9.6.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.4.0"
7
+ version = "9.6.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.4.0
3
+ Version: 9.6.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
@@ -17,7 +17,6 @@ import base64
17
17
  import datetime
18
18
  import fnmatch
19
19
  import functools
20
- import gzip
21
20
  import hashlib
22
21
  import hmac
23
22
  import importlib.metadata
@@ -30,6 +29,7 @@ import secrets
30
29
  import shutil
31
30
  import shlex
32
31
  import signal
32
+ import socket
33
33
  import subprocess
34
34
  import sys
35
35
  import tempfile
@@ -46,7 +46,7 @@ import zlib
46
46
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
47
  from pathlib import Path
48
48
 
49
- SOURCE_BACKEND_VERSION = "9.4.0"
49
+ SOURCE_BACKEND_VERSION = "9.6.0"
50
50
 
51
51
  def _read_source_version(filename: str) -> str | None:
52
52
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -1344,15 +1344,32 @@ def set_project_directory(path) -> dict:
1344
1344
  return {"cwd": str(target), "previous_cwd": previous}
1345
1345
 
1346
1346
 
1347
- def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = False) -> dict:
1347
+ def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
1348
+ """Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
1349
+
1350
+ Sin diff.guitool ni merge.guitool, git recurre a diff.tool o adivina una, que puede ser de
1351
+ terminal (vimdiff): lanzada sin terminal quedaría invisible y colgada.
1352
+ """
1353
+ for key in ("diff.guitool", "merge.guitool"):
1354
+ res = subprocess.run(
1355
+ [git_bin, "config", "--get", key],
1356
+ cwd=str(repo_root),
1357
+ capture_output=True,
1358
+ text=True,
1359
+ timeout=5,
1360
+ check=False
1361
+ )
1362
+ if res.returncode == 0 and res.stdout.strip():
1363
+ return res.stdout.strip()
1364
+ return ""
1365
+
1366
+
1367
+ def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
1348
1368
  """Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío."""
1349
1369
  if not isinstance(path, str) or not path.strip():
1350
1370
  raise ValueError("La ruta del archivo está vacía.")
1351
1371
  if len(path) > MAX_PATH_LENGTH:
1352
1372
  raise ValueError("La ruta del archivo excede el tamaño máximo permitido.")
1353
- if tool is not None:
1354
- if not isinstance(tool, str) or not re.match(r"^[a-zA-Z0-9_-]{1,32}$", tool):
1355
- raise ValueError("Nombre de herramienta de diff inválido.")
1356
1373
 
1357
1374
  target = Path(path.strip()).expanduser().resolve()
1358
1375
  if not target.exists():
@@ -1380,6 +1397,11 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
1380
1397
  raise ValueError(f"El archivo '{target.name}' no pertenece a ningún repositorio Git.")
1381
1398
 
1382
1399
  repo_root = Path(res.stdout.strip()).resolve()
1400
+ if not _configured_gui_diff_tool(git_bin, repo_root):
1401
+ raise ValueError(
1402
+ "No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
1403
+ "git config --global diff.guitool meld"
1404
+ )
1383
1405
 
1384
1406
  has_head = subprocess.run(
1385
1407
  [git_bin, "rev-parse", "--verify", "HEAD"],
@@ -1414,11 +1436,7 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
1414
1436
  except ValueError:
1415
1437
  in_head = False
1416
1438
 
1417
- cmd = [git_bin, "difftool", "--no-prompt", "-y"]
1418
- if tool:
1419
- cmd.append(f"--tool={tool}")
1420
- else:
1421
- cmd.append("--gui")
1439
+ cmd = [git_bin, "difftool", "--no-prompt", "--gui"]
1422
1440
 
1423
1441
  mode = "working_tree"
1424
1442
  last_commit = ""
@@ -1465,6 +1483,7 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
1465
1483
 
1466
1484
  popen_kwargs = {
1467
1485
  "cwd": str(repo_root),
1486
+ "stdin": subprocess.DEVNULL,
1468
1487
  "stdout": subprocess.DEVNULL,
1469
1488
  "stderr": subprocess.DEVNULL,
1470
1489
  }
@@ -2785,7 +2804,8 @@ HEARTBEAT_WATCHDOG_STOP = threading.Event()
2785
2804
  _SERVER_SHUTTING_DOWN = threading.Event()
2786
2805
  # Si el servidor se detiene al cerrar el navegador, cerrar la pestaña detiene también los comandos.
2787
2806
  EXIT_ON_CLOSE = False
2788
- QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list"})
2807
+ # zerochat/run/progress ya se registra con su propio texto en dispatch_run.
2808
+ QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list", "zerochat/run/progress"})
2789
2809
 
2790
2810
 
2791
2811
  def mark_browser_active():
@@ -2860,59 +2880,83 @@ METADATA_HOSTS = frozenset({
2860
2880
  "100.100.100.200",
2861
2881
  })
2862
2882
  DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
2863
-
2864
-
2865
- def is_dangerous_host(hostname: str, allow_local: bool = False) -> bool:
2866
- """Verifica si un host representa un riesgo de SSRF (metadatos cloud, loopback o privados)."""
2867
- if not hostname:
2883
+ WEB_FETCH_MAX_BYTES = 10 * 1024 * 1024
2884
+ WEB_FETCH_MAX_REDIRECTS = 5
2885
+ WEB_FETCH_READ_CHUNK = 64 * 1024
2886
+ TEXT_CONTENT_TYPE_RE = re.compile(
2887
+ r"^(text/|application/(json|xml|xhtml\+xml|javascript|x-javascript|ecmascript)$|[a-z]+/[^;]*\+(json|xml)$)"
2888
+ )
2889
+
2890
+
2891
+ def _is_public_ip(address: str) -> bool:
2892
+ """Una dirección es pública si es global y no multicast; las IPv4 mapeadas en IPv6 se evalúan como IPv4."""
2893
+ ip = ipaddress.ip_address(address.split("%", 1)[0])
2894
+ if ip.version == 6 and ip.ipv4_mapped:
2895
+ ip = ip.ipv4_mapped
2896
+ return ip.is_global and not ip.is_multicast
2897
+
2898
+
2899
+ def is_dangerous_host(hostname: str) -> bool:
2900
+ """Riesgo de SSRF: metadatos cloud, nombres locales o cualquier dirección resuelta no pública."""
2901
+ clean_host = (hostname or "").strip().lower().strip("[]").rstrip(".")
2902
+ if not clean_host or clean_host in METADATA_HOSTS or clean_host == "localhost":
2868
2903
  return True
2869
- clean_host = hostname.strip().lower().strip("[]")
2870
- if clean_host in METADATA_HOSTS:
2904
+ if clean_host.endswith((".localhost", ".local", ".internal")):
2871
2905
  return True
2872
-
2873
2906
  try:
2874
- ip = ipaddress.ip_address(clean_host)
2875
- if ip.is_link_local:
2876
- return True
2877
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2878
- return True
2907
+ infos = socket.getaddrinfo(clean_host, None, proto=socket.IPPROTO_TCP)
2908
+ except (socket.gaierror, UnicodeError):
2909
+ # Sin resolución no hay conexión posible: la petición fallará por sí misma.
2879
2910
  return False
2880
- except ValueError:
2881
- pass
2911
+ return any(not _is_public_ip(info[4][0]) for info in infos)
2882
2912
 
2883
- if clean_host.isdigit():
2884
- try:
2885
- num = int(clean_host)
2886
- if 0 <= num <= 4294967295:
2887
- ip = ipaddress.IPv4Address(num)
2888
- if ip.is_link_local:
2889
- return True
2890
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2891
- return True
2892
- return False
2893
- except ValueError:
2894
- pass
2895
2913
 
2896
- if clean_host.startswith("0x"):
2897
- try:
2898
- num = int(clean_host, 16)
2899
- if 0 <= num <= 4294967295:
2900
- ip = ipaddress.IPv4Address(num)
2901
- if ip.is_link_local:
2902
- return True
2903
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2904
- return True
2905
- return False
2906
- except ValueError:
2907
- pass
2914
+ def _validate_web_url(url: str) -> str:
2915
+ """Valida esquema y host de una URL de descarga (también de cada redirección)."""
2916
+ parsed = urllib.parse.urlparse(url)
2917
+ if parsed.scheme not in ("http", "https"):
2918
+ raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2919
+ if not parsed.hostname:
2920
+ raise ValueError("URL sin nombre de host válido.")
2921
+ if is_dangerous_host(parsed.hostname):
2922
+ raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2923
+ return url
2908
2924
 
2909
- if not allow_local:
2910
- if clean_host in ("localhost", "0.0.0.0", "127.0.0.1", "::1"):
2911
- return True
2912
- if clean_host.endswith(".local") or clean_host.endswith(".internal"):
2913
- return True
2914
2925
 
2915
- return False
2926
+ class _ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler):
2927
+ """Sigue redirecciones solo si el destino supera la misma validación anti-SSRF que la URL pedida."""
2928
+ max_redirections = WEB_FETCH_MAX_REDIRECTS
2929
+
2930
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
2931
+ _validate_web_url(newurl)
2932
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
2933
+
2934
+
2935
+ _WEB_OPENER = urllib.request.build_opener(_ValidatingRedirectHandler)
2936
+
2937
+
2938
+ def _read_limited_body(stream, content_encoding: str, max_bytes: int) -> tuple[bytes, bool]:
2939
+ """Lee el cuerpo ya descomprimido hasta `max_bytes`; indica si había más (truncado)."""
2940
+ encoding = (content_encoding or "").strip().lower()
2941
+ # MAX_WBITS | 32 detecta la cabecera gzip o zlib.
2942
+ decompressor = zlib.decompressobj(zlib.MAX_WBITS | 32) if encoding in ("gzip", "x-gzip", "deflate") else None
2943
+ data = bytearray()
2944
+ while len(data) <= max_bytes:
2945
+ chunk = stream.read(WEB_FETCH_READ_CHUNK)
2946
+ if not chunk:
2947
+ break
2948
+ if decompressor:
2949
+ # La salida se limita al hueco restante: un cuerpo comprimido no puede crecer sin tope.
2950
+ chunk = decompressor.decompress(chunk, max_bytes + 1 - len(data))
2951
+ data += chunk
2952
+ truncated = len(data) > max_bytes
2953
+ return bytes(data[:max_bytes]), truncated
2954
+
2955
+
2956
+ def is_text_content_type(content_type: str) -> bool:
2957
+ """Tipos que se devuelven como texto; el resto viaja en base64."""
2958
+ media_type = (content_type or "").split(";", 1)[0].strip().lower()
2959
+ return not media_type or bool(TEXT_CONTENT_TYPE_RE.match(media_type))
2916
2960
 
2917
2961
 
2918
2962
  def fetch_web_resource(params: dict) -> dict:
@@ -2920,97 +2964,48 @@ def fetch_web_resource(params: dict) -> dict:
2920
2964
  url = params.get("url")
2921
2965
  if not isinstance(url, str) or not url.strip():
2922
2966
  raise ValueError("URL no válida o ausente.")
2923
- url = url.strip()
2924
- parsed = urllib.parse.urlparse(url)
2925
- if parsed.scheme not in ("http", "https"):
2926
- raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2927
- hostname = (parsed.hostname or "").lower().strip("[]")
2928
- if not hostname:
2929
- raise ValueError("URL sin nombre de host válido.")
2967
+ url = _validate_web_url(url.strip())
2930
2968
 
2931
- allow_local = bool(params.get("allow_local", False))
2932
- if is_dangerous_host(hostname, allow_local):
2933
- raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2934
-
2935
- timeout_s = float(params.get("timeout_s", 10.0))
2936
- timeout_s = max(0.5, min(timeout_s, 30.0))
2937
- max_bytes = int(params.get("max_bytes", 2 * 1024 * 1024))
2938
- max_bytes = max(1024, min(max_bytes, 10 * 1024 * 1024))
2969
+ timeout_s = max(0.5, min(float(params.get("timeout_s", 10.0)), 30.0))
2970
+ max_bytes = max(1024, min(int(params.get("max_bytes", 2 * 1024 * 1024)), WEB_FETCH_MAX_BYTES))
2939
2971
  as_binary = bool(params.get("binary", False))
2940
-
2972
+ accept = params.get("accept")
2941
2973
  req_headers = {
2942
2974
  "User-Agent": DEFAULT_USER_AGENT,
2943
- "Accept": "text/html,application/xhtml+xml,application/json,application/pdf,text/plain;q=0.9,*/*;q=0.8",
2975
+ "Accept": accept if isinstance(accept, str) and accept else "*/*",
2944
2976
  "Accept-Encoding": "gzip, deflate",
2945
2977
  }
2946
- custom_headers = params.get("headers")
2947
- if isinstance(custom_headers, dict):
2948
- for k, v in custom_headers.items():
2949
- if isinstance(k, str) and isinstance(v, str) and k.lower() != "host":
2950
- req_headers[k] = v
2951
2978
 
2952
2979
  req = urllib.request.Request(url, headers=req_headers)
2953
2980
  try:
2954
- with urllib.request.urlopen(req, timeout=timeout_s) as resp:
2955
- status = resp.status
2981
+ with _WEB_OPENER.open(req, timeout=timeout_s) as resp:
2956
2982
  content_type = resp.headers.get("Content-Type", "")
2957
- content_encoding = resp.headers.get("Content-Encoding", "").lower()
2958
- raw_data = resp.read(max_bytes + 1)
2959
- truncated = len(raw_data) > max_bytes
2960
- if truncated:
2961
- raw_data = raw_data[:max_bytes]
2962
-
2963
- if "gzip" in content_encoding:
2964
- try:
2965
- raw_data = gzip.decompress(raw_data)
2966
- except (OSError, gzip.BadGzipFile):
2967
- pass
2968
- elif "deflate" in content_encoding:
2969
- try:
2970
- raw_data = zlib.decompress(raw_data)
2971
- except (OSError, zlib.error):
2972
- pass
2973
-
2974
- resp_headers = {k: v for k, v in resp.getheaders()}
2975
-
2976
- if as_binary:
2977
- return {
2978
- "ok": True,
2979
- "status": status,
2980
- "content_type": content_type,
2981
- "headers": resp_headers,
2982
- "base64": base64.b64encode(raw_data).decode("ascii"),
2983
- "byte_size": len(raw_data),
2984
- "truncated": truncated
2985
- }
2986
-
2983
+ raw_data, truncated = _read_limited_body(resp, resp.headers.get("Content-Encoding", ""), max_bytes)
2984
+ # Las cabeceras de transporte ya no describen el cuerpo descomprimido.
2985
+ resp_headers = {k: v for k, v in resp.getheaders() if k.lower() not in ("content-encoding", "content-length")}
2986
+ result = {
2987
+ "ok": True,
2988
+ "status": resp.status,
2989
+ "url": resp.geturl(),
2990
+ "content_type": content_type,
2991
+ "headers": resp_headers,
2992
+ "byte_size": len(raw_data),
2993
+ "truncated": truncated
2994
+ }
2995
+ if as_binary or not is_text_content_type(content_type):
2996
+ result["base64"] = base64.b64encode(raw_data).decode("ascii")
2997
+ return result
2987
2998
  charset = resp.headers.get_content_charset() or "utf-8"
2988
2999
  try:
2989
- text_content = raw_data.decode(charset, errors="replace")
2990
- return {
2991
- "ok": True,
2992
- "status": status,
2993
- "content_type": content_type,
2994
- "headers": resp_headers,
2995
- "text": text_content,
2996
- "byte_size": len(raw_data),
2997
- "truncated": truncated
2998
- }
2999
- except (UnicodeDecodeError, LookupError):
3000
- return {
3001
- "ok": True,
3002
- "status": status,
3003
- "content_type": content_type,
3004
- "headers": resp_headers,
3005
- "base64": base64.b64encode(raw_data).decode("ascii"),
3006
- "byte_size": len(raw_data),
3007
- "truncated": truncated
3008
- }
3000
+ result["text"] = raw_data.decode(charset, errors="replace")
3001
+ except LookupError:
3002
+ result["text"] = raw_data.decode("utf-8", errors="replace")
3003
+ return result
3009
3004
  except urllib.error.HTTPError as he:
3010
3005
  err_body = ""
3011
3006
  try:
3012
3007
  err_body = he.read(2048).decode("utf-8", errors="replace")
3013
- except (OSError, UnicodeDecodeError):
3008
+ except OSError:
3014
3009
  pass
3015
3010
  return {
3016
3011
  "ok": False,
@@ -3283,9 +3278,10 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3283
3278
  self._log_req("POST", safe_path)
3284
3279
  self._reject(400, {"error": "Invalid Content-Length"}, safe_path, t0, "Content-Length negativo")
3285
3280
  return None
3286
- if content_len > MAX_HTTP_BODY_BYTES:
3281
+ body_limit = http_body_limit()
3282
+ if content_len > body_limit:
3287
3283
  self._log_req("POST", safe_path)
3288
- if content_len <= MAX_HTTP_BODY_BYTES * 2:
3284
+ if content_len <= body_limit * 2:
3289
3285
  try:
3290
3286
  self.rfile.read(content_len)
3291
3287
  except (OSError, ValueError):
@@ -3432,8 +3428,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3432
3428
  return None, {"code": -32602, "message": str(exc)}, ""
3433
3429
  if method == "zerochat/diff/open":
3434
3430
  try:
3435
- commit_fallback = bool(params.get("commit_fallback") or params.get("commitFallback"))
3436
- return open_file_diff(params.get("path"), params.get("tool"), commit_fallback), None, ""
3431
+ return open_file_diff(params.get("path"), bool(params.get("commit_fallback"))), None, ""
3437
3432
  except (ValueError, RuntimeError) as exc:
3438
3433
  return None, {"code": -32602, "message": str(exc)}, ""
3439
3434
  except Exception as exc:
@@ -3447,6 +3442,8 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3447
3442
  return None, {"code": -32603, "message": f"Error en transporte web: {exc}"}, str(exc)
3448
3443
  if method.startswith("zerochat/jobs/"):
3449
3444
  return self._dispatch_jobs(method, params)
3445
+ if method.startswith("zerochat/run/"):
3446
+ return dispatch_run(method, params)
3450
3447
  if method == "zerochat/external/status":
3451
3448
  return {"host": "running", "version": VERSION, "servers": GLOBAL_MCP_MANAGER.list_servers()}, None, ""
3452
3449
  if method in ("zerochat/external/servers/start", "zerochat/external/servers/stop", "zerochat/external/servers/configure"):
@@ -3726,6 +3723,233 @@ def launch_browser(url: str) -> bool:
3726
3723
 
3727
3724
 
3728
3725
 
3726
+
3727
+
3728
+ def default_browser_executable() -> str | None:
3729
+ """Ejecutable del navegador predeterminado en Linux (xdg-settings y su fichero .desktop), o None."""
3730
+ if not sys.platform.startswith("linux") or is_termux_environment() or not shutil.which("xdg-settings"):
3731
+ return None
3732
+ try:
3733
+ desktop = subprocess.run(["xdg-settings", "get", "default-web-browser"], capture_output=True, text=True, timeout=5).stdout.strip()
3734
+ except (OSError, subprocess.SubprocessError):
3735
+ return None
3736
+ if not desktop.endswith(".desktop") or "/" in desktop:
3737
+ return None
3738
+ data_dirs = [os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")]
3739
+ data_dirs += (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")
3740
+ for data_dir in data_dirs:
3741
+ entry = Path(data_dir) / "applications" / desktop
3742
+ if not entry.is_file():
3743
+ continue
3744
+ try:
3745
+ lines = entry.read_text(encoding="utf-8", errors="replace").splitlines()
3746
+ except OSError:
3747
+ return None
3748
+ exec_line = next((line[5:] for line in lines if line.startswith("Exec=")), "")
3749
+ try:
3750
+ tokens = [token for token in shlex.split(exec_line) if token != "env" and "=" not in token]
3751
+ except ValueError:
3752
+ return None
3753
+ # Flatpak o Snap envuelven el navegador: no admiten sus opciones directamente.
3754
+ if not tokens or Path(tokens[0]).name in ("flatpak", "snap"):
3755
+ return None
3756
+ return shutil.which(tokens[0])
3757
+ return None
3758
+
3759
+
3760
+ def launch_headless_browser(url: str) -> subprocess.Popen | None:
3761
+ """
3762
+ Abre la URL en el navegador predeterminado con --headless y el perfil normal del usuario.
3763
+ Si el navegador ya está abierto, este suele recibir la URL como una pestaña visible.
3764
+ Devuelve el proceso lanzado, o None si no se pudo y hay que abrirlo de la forma normal.
3765
+ """
3766
+ executable = default_browser_executable()
3767
+ if not executable:
3768
+ log_event("No se pudo identificar el navegador predeterminado para abrirlo sin ventana; se abrirá de la forma normal.")
3769
+ return None
3770
+ log_event(f"Abriendo {Path(executable).name} sin ventana en la interfaz configurada...")
3771
+ try:
3772
+ return subprocess.Popen(
3773
+ [executable, "--headless", url],
3774
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
3775
+ start_new_session=True,
3776
+ )
3777
+ except OSError as error:
3778
+ log_event(f"No se pudo abrir {executable} sin ventana ({error}); se abrirá de la forma normal.")
3779
+ return None
3780
+ # ==============================================================================
3781
+ # Modo ejecución desatendida (zerochat.py --prompt)
3782
+ # ==============================================================================
3783
+ # El servidor arranca y abre el navegador como siempre, con run=<id> en el fragmento: la interfaz
3784
+ # pide el prompt, informa del progreso y devuelve el resultado por JSON-RPC zerochat/run/*.
3785
+
3786
+ # Plazo total y sin progreso de la interfaz; las variables de entorno solo las usan las pruebas.
3787
+ RUN_TOTAL_TIMEOUT = float(os.environ.get("ZEROCHAT_RUN_TIMEOUT", "900"))
3788
+ RUN_IDLE_SECONDS = float(os.environ.get("ZEROCHAT_RUN_IDLE_SECONDS", "180"))
3789
+ RUN_POLL_SECONDS = 0.2
3790
+ RUN_PROGRESS_TEXT_MAX = 500
3791
+ RUN_PROGRESS_KIND_PATTERN = re.compile(r"[a-z_]{1,32}")
3792
+ RUN_EXIT_DONE = 0
3793
+ RUN_EXIT_ERROR = 1
3794
+ RUN_EXIT_TIMEOUT = 2
3795
+ RUN_EXIT_SETUP = 3
3796
+ # Argumentos no válidos, en cualquier modo: argparse usaría 2, que con --prompt es plazo agotado.
3797
+ RUN_EXIT_ARGS = 4
3798
+ # Formato de --export según la extensión del fichero; la interfaz genera el contenido con ChatExport.
3799
+ RUN_EXPORT_FORMATS = {".md": "markdown", ".json": "json", ".html": "html"}
3800
+ # Cuerpo HTTP admitido mientras se espera la exportación: el JSON y el HTML llevan las imágenes incrustadas.
3801
+ RUN_EXPORT_MAX_BODY_BYTES = 64 * 1024 * 1024
3802
+
3803
+
3804
+ class RunSession:
3805
+ """Estado de la ejecución: prompt, opciones, último progreso, exportación, resultado y motivo de un final sin él."""
3806
+ def __init__(self, prompt: str, profile: str | None = None, export_path: Path | None = None, keep_page: bool = False,
3807
+ allow_dangerous_tools: bool = False):
3808
+ self.run_id = secrets.token_urlsafe(24)
3809
+ self.prompt = prompt
3810
+ # Perfil de conexión (nombre o id) que la interfaz usa solo en esta ejecución; None, el activo.
3811
+ self.profile = profile
3812
+ # Fichero donde se escribe la conversación exportada (formato por su extensión); None, sin exportar.
3813
+ self.export_path = export_path
3814
+ self.exported = False
3815
+ self.export_error: str | None = None
3816
+ # La interfaz no cierra la página al terminar, para ver la conversación (sin --headless).
3817
+ self.keep_page = keep_page
3818
+ # La interfaz aprueba también write_file, edit_file y run_command, que si no deniega.
3819
+ self.allow_dangerous_tools = allow_dangerous_tools
3820
+ self.lock = threading.Lock()
3821
+ self.last_activity = time.monotonic()
3822
+ self.prompt_delivered = False
3823
+ self.result: dict | None = None
3824
+ self.stop_reason: str | None = None
3825
+ self.finished = threading.Event()
3826
+
3827
+ def touch(self):
3828
+ with self.lock:
3829
+ self.last_activity = time.monotonic()
3830
+
3831
+ def idle_seconds(self) -> float:
3832
+ with self.lock:
3833
+ return time.monotonic() - self.last_activity
3834
+
3835
+
3836
+ RUN_SESSION: RunSession | None = None
3837
+
3838
+
3839
+ def http_body_limit() -> int:
3840
+ """Tope del cuerpo de una petición: el general, o el de la exportación mientras se espera."""
3841
+ session = RUN_SESSION
3842
+ if session is not None and session.export_path is not None and not session.exported:
3843
+ return RUN_EXPORT_MAX_BODY_BYTES
3844
+ return MAX_HTTP_BODY_BYTES
3845
+
3846
+
3847
+ def dispatch_run(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
3848
+ """Métodos zerochat/run/* que usa la interfaz abierta por el modo ejecución."""
3849
+ session = RUN_SESSION
3850
+ if session is None:
3851
+ return None, {"code": -32601, "message": "El servidor no está en modo ejecución."}, ""
3852
+ run_id = params.get("run_id")
3853
+ if not isinstance(run_id, str) or not hmac.compare_digest(run_id, session.run_id):
3854
+ return None, {"code": -32602, "message": "run_id no válido."}, ""
3855
+ if method == "zerochat/run/prompt":
3856
+ with session.lock:
3857
+ if session.prompt_delivered:
3858
+ return None, {"code": -32602, "message": "El prompt ya se entregó."}, ""
3859
+ session.prompt_delivered = True
3860
+ session.touch()
3861
+ export_format = RUN_EXPORT_FORMATS[session.export_path.suffix.lower()] if session.export_path else None
3862
+ return {"prompt": session.prompt, "profile": session.profile, "export": export_format, "keep_page": session.keep_page,
3863
+ "allow_dangerous_tools": session.allow_dangerous_tools}, None, ""
3864
+ if method == "zerochat/run/progress":
3865
+ kind = params.get("kind")
3866
+ text = params.get("text", "")
3867
+ if not isinstance(kind, str) or not RUN_PROGRESS_KIND_PATTERN.fullmatch(kind) or not isinstance(text, str):
3868
+ return None, {"code": -32602, "message": "Progreso no válido."}, ""
3869
+ session.touch()
3870
+ if len(text) > RUN_PROGRESS_TEXT_MAX:
3871
+ text = text[:RUN_PROGRESS_TEXT_MAX] + "…"
3872
+ log_event(f"[run] {kind}{': ' + text if text else ''}")
3873
+ return {"ok": True}, None, ""
3874
+ if method == "zerochat/run/export":
3875
+ content = params.get("content")
3876
+ if session.export_path is None or not isinstance(content, str):
3877
+ return None, {"code": -32602, "message": "Exportación no pedida o sin contenido."}, ""
3878
+ with session.lock:
3879
+ if session.exported:
3880
+ return None, {"code": -32602, "message": "La exportación ya se recibió."}, ""
3881
+ try:
3882
+ session.export_path.write_text(content, encoding="utf-8")
3883
+ except OSError as exc:
3884
+ session.export_error = f"No se pudo escribir {session.export_path}: {exc}"
3885
+ return None, {"code": -32603, "message": session.export_error}, ""
3886
+ session.exported = True
3887
+ session.touch()
3888
+ log_event(f"[run] Conversación exportada en {session.export_path}")
3889
+ return {"ok": True}, None, ""
3890
+ if method == "zerochat/run/result":
3891
+ text = params.get("text")
3892
+ error = params.get("error")
3893
+ if not ((isinstance(text, str) and error is None) or (isinstance(error, str) and error and text is None)):
3894
+ return None, {"code": -32602, "message": "El resultado lleva text o error."}, ""
3895
+ with session.lock:
3896
+ if session.result is not None:
3897
+ return None, {"code": -32602, "message": "El resultado ya se recibió."}, ""
3898
+ session.result = {"text": text} if error is None else {"error": error}
3899
+ session.finished.set()
3900
+ return {"ok": True}, None, ""
3901
+ return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
3902
+
3903
+
3904
+ def run_supervisor(server: ThreadingHTTPServer, session: RunSession):
3905
+ """Detiene el servidor al recibir el resultado o al agotar el plazo total o el de inactividad."""
3906
+ deadline = time.monotonic() + RUN_TOTAL_TIMEOUT
3907
+ while not session.finished.wait(RUN_POLL_SECONDS):
3908
+ # El latido o una señal ya detienen el servidor: el resultado queda vacío.
3909
+ if _SERVER_SHUTTING_DOWN.is_set():
3910
+ return
3911
+ if time.monotonic() >= deadline:
3912
+ session.stop_reason = f"Plazo total agotado ({RUN_TOTAL_TIMEOUT:.0f}s)."
3913
+ break
3914
+ if session.idle_seconds() > RUN_IDLE_SECONDS:
3915
+ session.stop_reason = f"Sin progreso de la interfaz durante {RUN_IDLE_SECONDS:.0f}s."
3916
+ break
3917
+ stop_zerochat_server(server)
3918
+
3919
+
3920
+ def run_error(message: str):
3921
+ """Motivo de un final sin respuesta: va a stderr aunque el registro esté desactivado (--log off)."""
3922
+ print(timestamp_message(f"[run] {message}"), file=sys.stderr, flush=True)
3923
+
3924
+
3925
+ def run_outcome(session: RunSession) -> tuple[int, str | None]:
3926
+ """
3927
+ Código de salida y respuesta final (solo si terminó bien), con el motivo de un fallo en stderr;
3928
+ una exportación pedida que no se escribió convierte un final correcto en error sin quitar la respuesta.
3929
+ """
3930
+ if session.export_path and not session.exported:
3931
+ run_error(session.export_error or f"La interfaz no envió la conversación para {session.export_path}.")
3932
+ if session.result is None:
3933
+ run_error(session.stop_reason or "El navegador se cerró o la ejecución se interrumpió sin resultado.")
3934
+ return RUN_EXIT_TIMEOUT, None
3935
+ if "error" in session.result:
3936
+ run_error(f"Error de generación: {session.result['error']}")
3937
+ return RUN_EXIT_ERROR, None
3938
+ log_event("[run] Ejecución terminada.")
3939
+ if session.export_path and not session.exported:
3940
+ return RUN_EXIT_ERROR, session.result["text"]
3941
+ return RUN_EXIT_DONE, session.result["text"]
3942
+
3943
+
3944
+ def fall_back_when_headless_exits(proc: subprocess.Popen, url: str):
3945
+ """
3946
+ Un navegador sin ventana termina enseguida si su perfil ya está abierto (Firefox avisa de que
3947
+ ya está en marcha): si acaba antes de que conecte la interfaz, se abre la pestaña normal.
3948
+ """
3949
+ proc.wait()
3950
+ if not HEARTBEAT_INITIALIZED and not _SERVER_SHUTTING_DOWN.is_set():
3951
+ log_event("El navegador sin ventana terminó sin abrir la interfaz (¿ya estaba abierto?); se abre una pestaña de la forma normal.")
3952
+ launch_browser(url)
3729
3953
  # ==============================================================================
3730
3954
  # Punto de Entrada Principal (CLI)
3731
3955
  # ==============================================================================
@@ -3751,26 +3975,71 @@ def remote_exposure_warning(host: str) -> str | None:
3751
3975
  )
3752
3976
 
3753
3977
 
3978
+ class ZeroChatArgumentParser(argparse.ArgumentParser):
3979
+ """Los errores de argumentos salen con RUN_EXIT_ARGS para no confundirse con los códigos de --prompt."""
3980
+ def error(self, message):
3981
+ self.print_usage(sys.stderr)
3982
+ self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
3983
+
3984
+
3754
3985
  def main():
3755
3986
  global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL, EXIT_ON_CLOSE
3756
- global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
3987
+ global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
3757
3988
 
3758
- parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
3989
+ parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
3759
3990
  # Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
3760
3991
  parser.color = False
3761
- parser.add_argument("--port", type=int, default=int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT)), help=f"Puerto de escucha (default: {DEFAULT_PORT})")
3992
+ parser.add_argument("--port", type=int, default=None, help=f"Puerto de escucha (default: ZEROCHAT_PORT o {DEFAULT_PORT}; con --prompt, si está ocupado se usa otro libre salvo en desarrollo)")
3762
3993
  parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
3763
3994
  parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
3764
3995
  parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
3765
- parser.add_argument("--no-exit-on-close", action="store_true", help="No detener el servidor automáticamente al cerrar el navegador")
3996
+ parser.add_argument("--keep-server", action="store_true", help="Servidor: seguir en marcha al cerrar el navegador (no se combina con --prompt)")
3766
3997
  parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
3767
3998
  parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
3999
+ parser.add_argument("--prompt", default=None, help="Ejecutar este prompt en la interfaz, imprimir solo la respuesta final y terminar (salida 0 terminado, 1 error de generación, 2 plazo agotado o navegador cerrado, 3 fallo de arranque, 4 argumentos no válidos)")
4000
+ parser.add_argument("--profile", default=None, help="Con --prompt, perfil de conexión (nombre o id) que se usa solo en esta ejecución, sin cambiar el activo")
4001
+ parser.add_argument("--log", choices=("on", "off"), default=None, help="Mostrar la cabecera y el registro del servidor (default: on; off con --prompt)")
4002
+ parser.add_argument("--export", default=None, metavar="FICHERO", help=f"Con --prompt, guardar la conversación en FICHERO; el formato sale de la extensión ({', '.join(RUN_EXPORT_FORMATS)})")
4003
+ parser.add_argument("--keep-page", action="store_true", help="Página: con --prompt, no cerrar la página al terminar para ver la conversación (no se combina con --headless)")
4004
+ 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")
4005
+ 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)")
3768
4006
  parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
3769
4007
  args = parser.parse_args()
3770
4008
 
3771
4009
  # Los avisos no persisten entre ejecuciones del servidor.
3772
4010
  reset_notices()
3773
4011
 
4012
+ # Modo ejecución: stdout queda solo para la respuesta final; todo el registro va a stderr.
4013
+ result_out = sys.stdout
4014
+ 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):
4015
+ parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
4016
+ if args.prompt is not None:
4017
+ if not args.prompt.strip():
4018
+ parser.error("el prompt está vacío")
4019
+ if args.no_browser:
4020
+ parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
4021
+ if args.keep_server:
4022
+ parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
4023
+ if args.profile is not None and not args.profile.strip():
4024
+ parser.error("--profile no puede estar vacío")
4025
+ if args.keep_page and args.headless:
4026
+ parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
4027
+ export_path = None
4028
+ if args.export is not None:
4029
+ export_path = Path(args.export).expanduser().absolute()
4030
+ if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
4031
+ parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
4032
+ if not export_path.parent.is_dir():
4033
+ parser.error(f"--export: no existe el directorio {export_path.parent}")
4034
+ RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
4035
+ # Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
4036
+ # y con --log off se descarta.
4037
+ log_enabled = args.log == "on" if args.log else RUN_SESSION is None
4038
+ if not log_enabled:
4039
+ sys.stdout = open(os.devnull, "w", encoding="utf-8")
4040
+ elif RUN_SESSION:
4041
+ sys.stdout = sys.stderr
4042
+
3774
4043
  if args.test:
3775
4044
  print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
3776
4045
  print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
@@ -3803,7 +4072,7 @@ def main():
3803
4072
  if not is_dev:
3804
4073
  threading.Thread(target=check_version, daemon=True).start()
3805
4074
 
3806
- ACTIVE_PORT = args.port
4075
+ ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
3807
4076
  ACTIVE_HOST = args.host
3808
4077
  SESSION_TOKEN = get_daily_token()
3809
4078
  # A restart deliberately invalidates every browser-side signing credential.
@@ -3812,7 +4081,25 @@ def main():
3812
4081
  with TOOL_AUTH_NONCES_LOCK:
3813
4082
  TOOL_AUTH_NONCES = {}
3814
4083
 
3815
- server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4084
+ # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4085
+ local_ui = static_root is not None and not args.ui_url
4086
+ try:
4087
+ server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4088
+ except OSError as exc:
4089
+ if not RUN_SESSION:
4090
+ raise
4091
+ if args.port is not None or local_ui:
4092
+ hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
4093
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
4094
+ sys.exit(RUN_EXIT_SETUP)
4095
+ # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4096
+ try:
4097
+ server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4098
+ except OSError as retry_exc:
4099
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4100
+ sys.exit(RUN_EXIT_SETUP)
4101
+ log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4102
+ ACTIVE_PORT = server.server_address[1]
3816
4103
  exposure_warning = remote_exposure_warning(ACTIVE_HOST)
3817
4104
  if exposure_warning:
3818
4105
  add_notice(exposure_warning)
@@ -3825,8 +4112,12 @@ def main():
3825
4112
  ui_url = DEFAULT_UI_URL
3826
4113
 
3827
4114
  browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
3828
- target_url = f"{ui_url}#{urlencode({'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT})}"
3829
- exit_on_close = not args.no_exit_on_close
4115
+ fragment = {'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
4116
+ if RUN_SESSION:
4117
+ fragment['run'] = RUN_SESSION.run_id
4118
+ target_url = f"{ui_url}#{urlencode(fragment)}"
4119
+ # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
4120
+ exit_on_close = not args.keep_server
3830
4121
  EXIT_ON_CLOSE = exit_on_close
3831
4122
 
3832
4123
  print("=" * 64)
@@ -3851,11 +4142,22 @@ def main():
3851
4142
  print(" Auto-cierre : Desactivado")
3852
4143
  print("=" * 64, flush=True)
3853
4144
 
3854
- CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
3855
- CONSOLE_CONTROL.start()
3856
-
3857
- if not args.no_browser:
3858
- launch_browser(target_url)
4145
+ headless_browser = None
4146
+ if RUN_SESSION:
4147
+ headless_browser = launch_headless_browser(target_url) if args.headless else None
4148
+ if headless_browser is None and not launch_browser(target_url):
4149
+ run_error("No se pudo abrir el navegador.")
4150
+ server.server_close()
4151
+ sys.stdout = result_out
4152
+ sys.exit(RUN_EXIT_SETUP)
4153
+ if headless_browser is not None:
4154
+ threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
4155
+ threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
4156
+ else:
4157
+ CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
4158
+ CONSOLE_CONTROL.start()
4159
+ if not args.no_browser:
4160
+ launch_browser(target_url)
3859
4161
 
3860
4162
  if exit_on_close:
3861
4163
  require_initial = not args.no_browser
@@ -3884,6 +4186,17 @@ def main():
3884
4186
  CONSOLE_CONTROL.close()
3885
4187
  CONSOLE_CONTROL = None
3886
4188
 
4189
+ if RUN_SESSION:
4190
+ # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
4191
+ if headless_browser is not None and headless_browser.poll() is None:
4192
+ _kill_process_tree(headless_browser)
4193
+ code, text = run_outcome(RUN_SESSION)
4194
+ sys.stdout = result_out
4195
+ if text is not None:
4196
+ result_out.write(text if text.endswith("\n") else text + "\n")
4197
+ result_out.flush()
4198
+ sys.exit(code)
4199
+
3887
4200
 
3888
4201
  if __name__ == "__main__":
3889
4202
  main()
File without changes
File without changes
File without changes