zerochat 9.3.0__tar.gz → 9.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.3.0
3
+ Version: 9.5.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.3.0"
7
+ version = "9.5.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.3.0
3
+ Version: 9.5.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.3.0"
49
+ SOURCE_BACKEND_VERSION = "9.5.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,6 +1344,169 @@ def set_project_directory(path) -> dict:
1344
1344
  return {"cwd": str(target), "previous_cwd": previous}
1345
1345
 
1346
1346
 
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:
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."""
1369
+ if not isinstance(path, str) or not path.strip():
1370
+ raise ValueError("La ruta del archivo está vacía.")
1371
+ if len(path) > MAX_PATH_LENGTH:
1372
+ raise ValueError("La ruta del archivo excede el tamaño máximo permitido.")
1373
+
1374
+ target = Path(path.strip()).expanduser().resolve()
1375
+ if not target.exists():
1376
+ raise ValueError(f"El archivo '{path}' no existe.")
1377
+ if not target.is_file():
1378
+ raise ValueError(f"'{path}' no es un archivo regular.")
1379
+
1380
+ git_bin = shutil.which("git")
1381
+ if not git_bin:
1382
+ raise RuntimeError("Git no está instalado o no se encuentra en el PATH del sistema.")
1383
+
1384
+ try:
1385
+ res = subprocess.run(
1386
+ [git_bin, "rev-parse", "--show-toplevel"],
1387
+ cwd=str(target.parent),
1388
+ capture_output=True,
1389
+ text=True,
1390
+ timeout=5,
1391
+ check=False
1392
+ )
1393
+ except Exception as err:
1394
+ raise RuntimeError(f"Error comprobando repositorio Git: {err}")
1395
+
1396
+ if res.returncode != 0:
1397
+ raise ValueError(f"El archivo '{target.name}' no pertenece a ningún repositorio Git.")
1398
+
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
+ )
1405
+
1406
+ has_head = subprocess.run(
1407
+ [git_bin, "rev-parse", "--verify", "HEAD"],
1408
+ cwd=str(repo_root),
1409
+ stdout=subprocess.DEVNULL,
1410
+ stderr=subprocess.DEVNULL,
1411
+ timeout=5,
1412
+ check=False
1413
+ ).returncode == 0
1414
+
1415
+ in_head = False
1416
+ has_working_tree_diff = True
1417
+ if has_head:
1418
+ try:
1419
+ rel_path = target.relative_to(repo_root).as_posix()
1420
+ in_head = subprocess.run(
1421
+ [git_bin, "cat-file", "-e", f"HEAD:{rel_path}"],
1422
+ cwd=str(repo_root),
1423
+ stdout=subprocess.DEVNULL,
1424
+ stderr=subprocess.DEVNULL,
1425
+ timeout=5,
1426
+ check=False
1427
+ ).returncode == 0
1428
+ if in_head:
1429
+ diff_check = subprocess.run(
1430
+ [git_bin, "diff", "--quiet", "HEAD", "--", str(target)],
1431
+ cwd=str(repo_root),
1432
+ timeout=5,
1433
+ check=False
1434
+ )
1435
+ has_working_tree_diff = (diff_check.returncode != 0)
1436
+ except ValueError:
1437
+ in_head = False
1438
+
1439
+ cmd = [git_bin, "difftool", "--no-prompt", "--gui"]
1440
+
1441
+ mode = "working_tree"
1442
+ last_commit = ""
1443
+ if has_working_tree_diff:
1444
+ if in_head:
1445
+ cmd.extend(["HEAD", "--", str(target)])
1446
+ else:
1447
+ cmd.extend(["--no-index", "--", os.devnull, str(target)])
1448
+ else:
1449
+ # No hay cambios pendientes en el árbol de trabajo. Comprobar historial del archivo.
1450
+ rel_path = target.relative_to(repo_root).as_posix()
1451
+ log_res = subprocess.run(
1452
+ [git_bin, "log", "-n", "1", "--format=%H", "--", rel_path],
1453
+ cwd=str(repo_root),
1454
+ capture_output=True,
1455
+ text=True,
1456
+ timeout=5,
1457
+ check=False
1458
+ )
1459
+ last_commit = log_res.stdout.strip() if log_res.returncode == 0 else ""
1460
+ if not commit_fallback or not last_commit:
1461
+ return {
1462
+ "success": True,
1463
+ "has_diff": False,
1464
+ "in_head": in_head,
1465
+ "has_commit_history": bool(last_commit),
1466
+ "last_commit": last_commit or None,
1467
+ "path": str(target)
1468
+ }
1469
+
1470
+ mode = "commit"
1471
+ parent_check = subprocess.run(
1472
+ [git_bin, "rev-parse", "--verify", f"{last_commit}~1"],
1473
+ cwd=str(repo_root),
1474
+ stdout=subprocess.DEVNULL,
1475
+ stderr=subprocess.DEVNULL,
1476
+ timeout=5,
1477
+ check=False
1478
+ )
1479
+ if parent_check.returncode == 0:
1480
+ cmd.extend([f"{last_commit}~1", last_commit, "--", str(target)])
1481
+ else:
1482
+ cmd.extend(["4b825dc642cb6eb9a060e54bf8d69288fbee4904", last_commit, "--", str(target)])
1483
+
1484
+ popen_kwargs = {
1485
+ "cwd": str(repo_root),
1486
+ "stdin": subprocess.DEVNULL,
1487
+ "stdout": subprocess.DEVNULL,
1488
+ "stderr": subprocess.DEVNULL,
1489
+ }
1490
+ if os.name == "nt":
1491
+ popen_kwargs["creationflags"] = (
1492
+ getattr(subprocess, "DETACHED_PROCESS", 0) |
1493
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
1494
+ )
1495
+ else:
1496
+ popen_kwargs["start_new_session"] = True
1497
+
1498
+ proc = subprocess.Popen(cmd, **popen_kwargs)
1499
+ return {
1500
+ "success": True,
1501
+ "has_diff": True,
1502
+ "mode": mode,
1503
+ "pid": proc.pid,
1504
+ "path": str(target),
1505
+ "in_head": in_head,
1506
+ "commit": last_commit or None
1507
+ }
1508
+
1509
+
1347
1510
  def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
1348
1511
  """Recursively search for plain text or regular expressions across project files."""
1349
1512
  try:
@@ -2716,59 +2879,83 @@ METADATA_HOSTS = frozenset({
2716
2879
  "100.100.100.200",
2717
2880
  })
2718
2881
  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"
2719
-
2720
-
2721
- def is_dangerous_host(hostname: str, allow_local: bool = False) -> bool:
2722
- """Verifica si un host representa un riesgo de SSRF (metadatos cloud, loopback o privados)."""
2723
- if not hostname:
2882
+ WEB_FETCH_MAX_BYTES = 10 * 1024 * 1024
2883
+ WEB_FETCH_MAX_REDIRECTS = 5
2884
+ WEB_FETCH_READ_CHUNK = 64 * 1024
2885
+ TEXT_CONTENT_TYPE_RE = re.compile(
2886
+ r"^(text/|application/(json|xml|xhtml\+xml|javascript|x-javascript|ecmascript)$|[a-z]+/[^;]*\+(json|xml)$)"
2887
+ )
2888
+
2889
+
2890
+ def _is_public_ip(address: str) -> bool:
2891
+ """Una dirección es pública si es global y no multicast; las IPv4 mapeadas en IPv6 se evalúan como IPv4."""
2892
+ ip = ipaddress.ip_address(address.split("%", 1)[0])
2893
+ if ip.version == 6 and ip.ipv4_mapped:
2894
+ ip = ip.ipv4_mapped
2895
+ return ip.is_global and not ip.is_multicast
2896
+
2897
+
2898
+ def is_dangerous_host(hostname: str) -> bool:
2899
+ """Riesgo de SSRF: metadatos cloud, nombres locales o cualquier dirección resuelta no pública."""
2900
+ clean_host = (hostname or "").strip().lower().strip("[]").rstrip(".")
2901
+ if not clean_host or clean_host in METADATA_HOSTS or clean_host == "localhost":
2724
2902
  return True
2725
- clean_host = hostname.strip().lower().strip("[]")
2726
- if clean_host in METADATA_HOSTS:
2903
+ if clean_host.endswith((".localhost", ".local", ".internal")):
2727
2904
  return True
2728
-
2729
2905
  try:
2730
- ip = ipaddress.ip_address(clean_host)
2731
- if ip.is_link_local:
2732
- return True
2733
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2734
- return True
2906
+ infos = socket.getaddrinfo(clean_host, None, proto=socket.IPPROTO_TCP)
2907
+ except (socket.gaierror, UnicodeError):
2908
+ # Sin resolución no hay conexión posible: la petición fallará por sí misma.
2735
2909
  return False
2736
- except ValueError:
2737
- pass
2910
+ return any(not _is_public_ip(info[4][0]) for info in infos)
2738
2911
 
2739
- if clean_host.isdigit():
2740
- try:
2741
- num = int(clean_host)
2742
- if 0 <= num <= 4294967295:
2743
- ip = ipaddress.IPv4Address(num)
2744
- if ip.is_link_local:
2745
- return True
2746
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2747
- return True
2748
- return False
2749
- except ValueError:
2750
- pass
2751
2912
 
2752
- if clean_host.startswith("0x"):
2753
- try:
2754
- num = int(clean_host, 16)
2755
- if 0 <= num <= 4294967295:
2756
- ip = ipaddress.IPv4Address(num)
2757
- if ip.is_link_local:
2758
- return True
2759
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2760
- return True
2761
- return False
2762
- except ValueError:
2763
- pass
2913
+ def _validate_web_url(url: str) -> str:
2914
+ """Valida esquema y host de una URL de descarga (también de cada redirección)."""
2915
+ parsed = urllib.parse.urlparse(url)
2916
+ if parsed.scheme not in ("http", "https"):
2917
+ raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2918
+ if not parsed.hostname:
2919
+ raise ValueError("URL sin nombre de host válido.")
2920
+ if is_dangerous_host(parsed.hostname):
2921
+ raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2922
+ return url
2764
2923
 
2765
- if not allow_local:
2766
- if clean_host in ("localhost", "0.0.0.0", "127.0.0.1", "::1"):
2767
- return True
2768
- if clean_host.endswith(".local") or clean_host.endswith(".internal"):
2769
- return True
2770
2924
 
2771
- return False
2925
+ class _ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler):
2926
+ """Sigue redirecciones solo si el destino supera la misma validación anti-SSRF que la URL pedida."""
2927
+ max_redirections = WEB_FETCH_MAX_REDIRECTS
2928
+
2929
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
2930
+ _validate_web_url(newurl)
2931
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
2932
+
2933
+
2934
+ _WEB_OPENER = urllib.request.build_opener(_ValidatingRedirectHandler)
2935
+
2936
+
2937
+ def _read_limited_body(stream, content_encoding: str, max_bytes: int) -> tuple[bytes, bool]:
2938
+ """Lee el cuerpo ya descomprimido hasta `max_bytes`; indica si había más (truncado)."""
2939
+ encoding = (content_encoding or "").strip().lower()
2940
+ # MAX_WBITS | 32 detecta la cabecera gzip o zlib.
2941
+ decompressor = zlib.decompressobj(zlib.MAX_WBITS | 32) if encoding in ("gzip", "x-gzip", "deflate") else None
2942
+ data = bytearray()
2943
+ while len(data) <= max_bytes:
2944
+ chunk = stream.read(WEB_FETCH_READ_CHUNK)
2945
+ if not chunk:
2946
+ break
2947
+ if decompressor:
2948
+ # La salida se limita al hueco restante: un cuerpo comprimido no puede crecer sin tope.
2949
+ chunk = decompressor.decompress(chunk, max_bytes + 1 - len(data))
2950
+ data += chunk
2951
+ truncated = len(data) > max_bytes
2952
+ return bytes(data[:max_bytes]), truncated
2953
+
2954
+
2955
+ def is_text_content_type(content_type: str) -> bool:
2956
+ """Tipos que se devuelven como texto; el resto viaja en base64."""
2957
+ media_type = (content_type or "").split(";", 1)[0].strip().lower()
2958
+ return not media_type or bool(TEXT_CONTENT_TYPE_RE.match(media_type))
2772
2959
 
2773
2960
 
2774
2961
  def fetch_web_resource(params: dict) -> dict:
@@ -2776,97 +2963,48 @@ def fetch_web_resource(params: dict) -> dict:
2776
2963
  url = params.get("url")
2777
2964
  if not isinstance(url, str) or not url.strip():
2778
2965
  raise ValueError("URL no válida o ausente.")
2779
- url = url.strip()
2780
- parsed = urllib.parse.urlparse(url)
2781
- if parsed.scheme not in ("http", "https"):
2782
- raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2783
- hostname = (parsed.hostname or "").lower().strip("[]")
2784
- if not hostname:
2785
- raise ValueError("URL sin nombre de host válido.")
2966
+ url = _validate_web_url(url.strip())
2786
2967
 
2787
- allow_local = bool(params.get("allow_local", False))
2788
- if is_dangerous_host(hostname, allow_local):
2789
- raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2790
-
2791
- timeout_s = float(params.get("timeout_s", 10.0))
2792
- timeout_s = max(0.5, min(timeout_s, 30.0))
2793
- max_bytes = int(params.get("max_bytes", 2 * 1024 * 1024))
2794
- max_bytes = max(1024, min(max_bytes, 10 * 1024 * 1024))
2968
+ timeout_s = max(0.5, min(float(params.get("timeout_s", 10.0)), 30.0))
2969
+ max_bytes = max(1024, min(int(params.get("max_bytes", 2 * 1024 * 1024)), WEB_FETCH_MAX_BYTES))
2795
2970
  as_binary = bool(params.get("binary", False))
2796
-
2971
+ accept = params.get("accept")
2797
2972
  req_headers = {
2798
2973
  "User-Agent": DEFAULT_USER_AGENT,
2799
- "Accept": "text/html,application/xhtml+xml,application/json,application/pdf,text/plain;q=0.9,*/*;q=0.8",
2974
+ "Accept": accept if isinstance(accept, str) and accept else "*/*",
2800
2975
  "Accept-Encoding": "gzip, deflate",
2801
2976
  }
2802
- custom_headers = params.get("headers")
2803
- if isinstance(custom_headers, dict):
2804
- for k, v in custom_headers.items():
2805
- if isinstance(k, str) and isinstance(v, str) and k.lower() != "host":
2806
- req_headers[k] = v
2807
2977
 
2808
2978
  req = urllib.request.Request(url, headers=req_headers)
2809
2979
  try:
2810
- with urllib.request.urlopen(req, timeout=timeout_s) as resp:
2811
- status = resp.status
2980
+ with _WEB_OPENER.open(req, timeout=timeout_s) as resp:
2812
2981
  content_type = resp.headers.get("Content-Type", "")
2813
- content_encoding = resp.headers.get("Content-Encoding", "").lower()
2814
- raw_data = resp.read(max_bytes + 1)
2815
- truncated = len(raw_data) > max_bytes
2816
- if truncated:
2817
- raw_data = raw_data[:max_bytes]
2818
-
2819
- if "gzip" in content_encoding:
2820
- try:
2821
- raw_data = gzip.decompress(raw_data)
2822
- except (OSError, gzip.BadGzipFile):
2823
- pass
2824
- elif "deflate" in content_encoding:
2825
- try:
2826
- raw_data = zlib.decompress(raw_data)
2827
- except (OSError, zlib.error):
2828
- pass
2829
-
2830
- resp_headers = {k: v for k, v in resp.getheaders()}
2831
-
2832
- if as_binary:
2833
- return {
2834
- "ok": True,
2835
- "status": status,
2836
- "content_type": content_type,
2837
- "headers": resp_headers,
2838
- "base64": base64.b64encode(raw_data).decode("ascii"),
2839
- "byte_size": len(raw_data),
2840
- "truncated": truncated
2841
- }
2842
-
2982
+ raw_data, truncated = _read_limited_body(resp, resp.headers.get("Content-Encoding", ""), max_bytes)
2983
+ # Las cabeceras de transporte ya no describen el cuerpo descomprimido.
2984
+ resp_headers = {k: v for k, v in resp.getheaders() if k.lower() not in ("content-encoding", "content-length")}
2985
+ result = {
2986
+ "ok": True,
2987
+ "status": resp.status,
2988
+ "url": resp.geturl(),
2989
+ "content_type": content_type,
2990
+ "headers": resp_headers,
2991
+ "byte_size": len(raw_data),
2992
+ "truncated": truncated
2993
+ }
2994
+ if as_binary or not is_text_content_type(content_type):
2995
+ result["base64"] = base64.b64encode(raw_data).decode("ascii")
2996
+ return result
2843
2997
  charset = resp.headers.get_content_charset() or "utf-8"
2844
2998
  try:
2845
- text_content = raw_data.decode(charset, errors="replace")
2846
- return {
2847
- "ok": True,
2848
- "status": status,
2849
- "content_type": content_type,
2850
- "headers": resp_headers,
2851
- "text": text_content,
2852
- "byte_size": len(raw_data),
2853
- "truncated": truncated
2854
- }
2855
- except (UnicodeDecodeError, LookupError):
2856
- return {
2857
- "ok": True,
2858
- "status": status,
2859
- "content_type": content_type,
2860
- "headers": resp_headers,
2861
- "base64": base64.b64encode(raw_data).decode("ascii"),
2862
- "byte_size": len(raw_data),
2863
- "truncated": truncated
2864
- }
2999
+ result["text"] = raw_data.decode(charset, errors="replace")
3000
+ except LookupError:
3001
+ result["text"] = raw_data.decode("utf-8", errors="replace")
3002
+ return result
2865
3003
  except urllib.error.HTTPError as he:
2866
3004
  err_body = ""
2867
3005
  try:
2868
3006
  err_body = he.read(2048).decode("utf-8", errors="replace")
2869
- except (OSError, UnicodeDecodeError):
3007
+ except OSError:
2870
3008
  pass
2871
3009
  return {
2872
3010
  "ok": False,
@@ -3286,6 +3424,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3286
3424
  return set_project_directory(params.get("path")), None, ""
3287
3425
  except (ValueError, OSError) as exc:
3288
3426
  return None, {"code": -32602, "message": str(exc)}, ""
3427
+ if method == "zerochat/diff/open":
3428
+ try:
3429
+ return open_file_diff(params.get("path"), bool(params.get("commit_fallback"))), None, ""
3430
+ except (ValueError, RuntimeError) as exc:
3431
+ return None, {"code": -32602, "message": str(exc)}, ""
3432
+ except Exception as exc:
3433
+ return None, {"code": -32603, "message": f"Error al abrir el visor de diffs: {exc}"}, str(exc)
3289
3434
  if method == "zerochat/web/fetch":
3290
3435
  try:
3291
3436
  return fetch_web_resource(params), None, ""
File without changes
File without changes
File without changes