zerochat 9.2.0__tar.gz → 9.4.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.2.0
3
+ Version: 9.4.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.2.0"
7
+ version = "9.4.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.2.0
3
+ Version: 9.4.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,6 +17,7 @@ import base64
17
17
  import datetime
18
18
  import fnmatch
19
19
  import functools
20
+ import gzip
20
21
  import hashlib
21
22
  import hmac
22
23
  import importlib.metadata
@@ -36,14 +37,16 @@ import threading
36
37
  import time
37
38
  import traceback
38
39
  import urllib.error
40
+ import urllib.parse
39
41
  import urllib.request
40
42
  from urllib.parse import urlencode
41
43
  import venv
42
44
  import webbrowser
45
+ import zlib
43
46
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
44
47
  from pathlib import Path
45
48
 
46
- SOURCE_BACKEND_VERSION = "9.2.0"
49
+ SOURCE_BACKEND_VERSION = "9.4.0"
47
50
 
48
51
  def _read_source_version(filename: str) -> str | None:
49
52
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -1341,6 +1344,150 @@ def set_project_directory(path) -> dict:
1341
1344
  return {"cwd": str(target), "previous_cwd": previous}
1342
1345
 
1343
1346
 
1347
+ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = False) -> dict:
1348
+ """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
+ if not isinstance(path, str) or not path.strip():
1350
+ raise ValueError("La ruta del archivo está vacía.")
1351
+ if len(path) > MAX_PATH_LENGTH:
1352
+ 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
+
1357
+ target = Path(path.strip()).expanduser().resolve()
1358
+ if not target.exists():
1359
+ raise ValueError(f"El archivo '{path}' no existe.")
1360
+ if not target.is_file():
1361
+ raise ValueError(f"'{path}' no es un archivo regular.")
1362
+
1363
+ git_bin = shutil.which("git")
1364
+ if not git_bin:
1365
+ raise RuntimeError("Git no está instalado o no se encuentra en el PATH del sistema.")
1366
+
1367
+ try:
1368
+ res = subprocess.run(
1369
+ [git_bin, "rev-parse", "--show-toplevel"],
1370
+ cwd=str(target.parent),
1371
+ capture_output=True,
1372
+ text=True,
1373
+ timeout=5,
1374
+ check=False
1375
+ )
1376
+ except Exception as err:
1377
+ raise RuntimeError(f"Error comprobando repositorio Git: {err}")
1378
+
1379
+ if res.returncode != 0:
1380
+ raise ValueError(f"El archivo '{target.name}' no pertenece a ningún repositorio Git.")
1381
+
1382
+ repo_root = Path(res.stdout.strip()).resolve()
1383
+
1384
+ has_head = subprocess.run(
1385
+ [git_bin, "rev-parse", "--verify", "HEAD"],
1386
+ cwd=str(repo_root),
1387
+ stdout=subprocess.DEVNULL,
1388
+ stderr=subprocess.DEVNULL,
1389
+ timeout=5,
1390
+ check=False
1391
+ ).returncode == 0
1392
+
1393
+ in_head = False
1394
+ has_working_tree_diff = True
1395
+ if has_head:
1396
+ try:
1397
+ rel_path = target.relative_to(repo_root).as_posix()
1398
+ in_head = subprocess.run(
1399
+ [git_bin, "cat-file", "-e", f"HEAD:{rel_path}"],
1400
+ cwd=str(repo_root),
1401
+ stdout=subprocess.DEVNULL,
1402
+ stderr=subprocess.DEVNULL,
1403
+ timeout=5,
1404
+ check=False
1405
+ ).returncode == 0
1406
+ if in_head:
1407
+ diff_check = subprocess.run(
1408
+ [git_bin, "diff", "--quiet", "HEAD", "--", str(target)],
1409
+ cwd=str(repo_root),
1410
+ timeout=5,
1411
+ check=False
1412
+ )
1413
+ has_working_tree_diff = (diff_check.returncode != 0)
1414
+ except ValueError:
1415
+ in_head = False
1416
+
1417
+ cmd = [git_bin, "difftool", "--no-prompt", "-y"]
1418
+ if tool:
1419
+ cmd.append(f"--tool={tool}")
1420
+ else:
1421
+ cmd.append("--gui")
1422
+
1423
+ mode = "working_tree"
1424
+ last_commit = ""
1425
+ if has_working_tree_diff:
1426
+ if in_head:
1427
+ cmd.extend(["HEAD", "--", str(target)])
1428
+ else:
1429
+ cmd.extend(["--no-index", "--", os.devnull, str(target)])
1430
+ else:
1431
+ # No hay cambios pendientes en el árbol de trabajo. Comprobar historial del archivo.
1432
+ rel_path = target.relative_to(repo_root).as_posix()
1433
+ log_res = subprocess.run(
1434
+ [git_bin, "log", "-n", "1", "--format=%H", "--", rel_path],
1435
+ cwd=str(repo_root),
1436
+ capture_output=True,
1437
+ text=True,
1438
+ timeout=5,
1439
+ check=False
1440
+ )
1441
+ last_commit = log_res.stdout.strip() if log_res.returncode == 0 else ""
1442
+ if not commit_fallback or not last_commit:
1443
+ return {
1444
+ "success": True,
1445
+ "has_diff": False,
1446
+ "in_head": in_head,
1447
+ "has_commit_history": bool(last_commit),
1448
+ "last_commit": last_commit or None,
1449
+ "path": str(target)
1450
+ }
1451
+
1452
+ mode = "commit"
1453
+ parent_check = subprocess.run(
1454
+ [git_bin, "rev-parse", "--verify", f"{last_commit}~1"],
1455
+ cwd=str(repo_root),
1456
+ stdout=subprocess.DEVNULL,
1457
+ stderr=subprocess.DEVNULL,
1458
+ timeout=5,
1459
+ check=False
1460
+ )
1461
+ if parent_check.returncode == 0:
1462
+ cmd.extend([f"{last_commit}~1", last_commit, "--", str(target)])
1463
+ else:
1464
+ cmd.extend(["4b825dc642cb6eb9a060e54bf8d69288fbee4904", last_commit, "--", str(target)])
1465
+
1466
+ popen_kwargs = {
1467
+ "cwd": str(repo_root),
1468
+ "stdout": subprocess.DEVNULL,
1469
+ "stderr": subprocess.DEVNULL,
1470
+ }
1471
+ if os.name == "nt":
1472
+ popen_kwargs["creationflags"] = (
1473
+ getattr(subprocess, "DETACHED_PROCESS", 0) |
1474
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
1475
+ )
1476
+ else:
1477
+ popen_kwargs["start_new_session"] = True
1478
+
1479
+ proc = subprocess.Popen(cmd, **popen_kwargs)
1480
+ return {
1481
+ "success": True,
1482
+ "has_diff": True,
1483
+ "mode": mode,
1484
+ "pid": proc.pid,
1485
+ "path": str(target),
1486
+ "in_head": in_head,
1487
+ "commit": last_commit or None
1488
+ }
1489
+
1490
+
1344
1491
  def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
1345
1492
  """Recursively search for plain text or regular expressions across project files."""
1346
1493
  try:
@@ -1350,9 +1497,11 @@ def search_files(query: str, path: str = ".", file_pattern: str = None, max_resu
1350
1497
  if not target.is_dir():
1351
1498
  return _tool_error(f"Path '{path}' is not a directory.")
1352
1499
 
1500
+ warning = None
1353
1501
  try:
1354
1502
  regex = re.compile(query, re.MULTILINE)
1355
- except re.error:
1503
+ except re.error as err:
1504
+ warning = f"Query '{query}' is not a valid regular expression ({err}); treated as literal text."
1356
1505
  regex = re.compile(re.escape(query), re.MULTILINE)
1357
1506
 
1358
1507
  try:
@@ -1412,7 +1561,7 @@ def search_files(query: str, path: str = ".", file_pattern: str = None, max_resu
1412
1561
  if truncated:
1413
1562
  break
1414
1563
 
1415
- return json.dumps({
1564
+ payload = {
1416
1565
  "success": True,
1417
1566
  "path": str(target),
1418
1567
  "query": query,
@@ -1421,7 +1570,11 @@ def search_files(query: str, path: str = ".", file_pattern: str = None, max_resu
1421
1570
  "total_matches": len(matches),
1422
1571
  "truncated": truncated,
1423
1572
  "matches": matches
1424
- }, ensure_ascii=False, indent=2)
1573
+ }
1574
+ if warning:
1575
+ payload["warning"] = warning
1576
+
1577
+ return json.dumps(payload, ensure_ascii=False, indent=2)
1425
1578
  except Exception as e:
1426
1579
  return _tool_error(str(e))
1427
1580
 
@@ -2700,6 +2853,178 @@ DEV_CONTENT_TYPES = {
2700
2853
  ".map": "application/json",
2701
2854
  }
2702
2855
 
2856
+ METADATA_HOSTS = frozenset({
2857
+ "169.254.169.254",
2858
+ "metadata.google.internal",
2859
+ "instance-data",
2860
+ "100.100.100.200",
2861
+ })
2862
+ 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:
2868
+ return True
2869
+ clean_host = hostname.strip().lower().strip("[]")
2870
+ if clean_host in METADATA_HOSTS:
2871
+ return True
2872
+
2873
+ 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
2879
+ return False
2880
+ except ValueError:
2881
+ pass
2882
+
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
+
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
2908
+
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
+
2915
+ return False
2916
+
2917
+
2918
+ def fetch_web_resource(params: dict) -> dict:
2919
+ """Descarga de forma segura un recurso web vía HTTP/HTTPS para el cliente web."""
2920
+ url = params.get("url")
2921
+ if not isinstance(url, str) or not url.strip():
2922
+ 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.")
2930
+
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))
2939
+ as_binary = bool(params.get("binary", False))
2940
+
2941
+ req_headers = {
2942
+ "User-Agent": DEFAULT_USER_AGENT,
2943
+ "Accept": "text/html,application/xhtml+xml,application/json,application/pdf,text/plain;q=0.9,*/*;q=0.8",
2944
+ "Accept-Encoding": "gzip, deflate",
2945
+ }
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
+
2952
+ req = urllib.request.Request(url, headers=req_headers)
2953
+ try:
2954
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
2955
+ status = resp.status
2956
+ 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
+
2987
+ charset = resp.headers.get_content_charset() or "utf-8"
2988
+ 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
+ }
3009
+ except urllib.error.HTTPError as he:
3010
+ err_body = ""
3011
+ try:
3012
+ err_body = he.read(2048).decode("utf-8", errors="replace")
3013
+ except (OSError, UnicodeDecodeError):
3014
+ pass
3015
+ return {
3016
+ "ok": False,
3017
+ "status": he.code,
3018
+ "error": f"HTTP {he.code}: {he.reason}",
3019
+ "text": err_body
3020
+ }
3021
+ except Exception as exc:
3022
+ return {
3023
+ "ok": False,
3024
+ "status": 0,
3025
+ "error": str(exc)
3026
+ }
3027
+
2703
3028
 
2704
3029
  class ZeroChatServerHandler(BaseHTTPRequestHandler):
2705
3030
  server_version = f"ZeroChatServer/{VERSION}"
@@ -3105,6 +3430,21 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3105
3430
  return set_project_directory(params.get("path")), None, ""
3106
3431
  except (ValueError, OSError) as exc:
3107
3432
  return None, {"code": -32602, "message": str(exc)}, ""
3433
+ if method == "zerochat/diff/open":
3434
+ 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, ""
3437
+ except (ValueError, RuntimeError) as exc:
3438
+ return None, {"code": -32602, "message": str(exc)}, ""
3439
+ except Exception as exc:
3440
+ return None, {"code": -32603, "message": f"Error al abrir el visor de diffs: {exc}"}, str(exc)
3441
+ if method == "zerochat/web/fetch":
3442
+ try:
3443
+ return fetch_web_resource(params), None, ""
3444
+ except (ValueError, PermissionError) as exc:
3445
+ return None, {"code": -32602, "message": str(exc)}, ""
3446
+ except Exception as exc:
3447
+ return None, {"code": -32603, "message": f"Error en transporte web: {exc}"}, str(exc)
3108
3448
  if method.startswith("zerochat/jobs/"):
3109
3449
  return self._dispatch_jobs(method, params)
3110
3450
  if method == "zerochat/external/status":
File without changes
File without changes
File without changes