zerochat 7.7.0__tar.gz → 7.8.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.
- {zerochat-7.7.0/zerochat.egg-info → zerochat-7.8.0}/PKG-INFO +1 -1
- {zerochat-7.7.0 → zerochat-7.8.0}/pyproject.toml +1 -1
- {zerochat-7.7.0 → zerochat-7.8.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-7.7.0 → zerochat-7.8.0}/zerochat.py +117 -20
- {zerochat-7.7.0 → zerochat-7.8.0}/LICENSE +0 -0
- {zerochat-7.7.0 → zerochat-7.8.0}/README.md +0 -0
- {zerochat-7.7.0 → zerochat-7.8.0}/setup.cfg +0 -0
- {zerochat-7.7.0 → zerochat-7.8.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-7.7.0 → zerochat-7.8.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-7.7.0 → zerochat-7.8.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-7.7.0 → zerochat-7.8.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -37,7 +37,7 @@ import webbrowser
|
|
|
37
37
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
38
38
|
from pathlib import Path
|
|
39
39
|
|
|
40
|
-
SOURCE_BACKEND_VERSION = "7.
|
|
40
|
+
SOURCE_BACKEND_VERSION = "7.8.0"
|
|
41
41
|
|
|
42
42
|
def _read_source_version(filename: str) -> str | None:
|
|
43
43
|
"""Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
|
|
@@ -388,8 +388,8 @@ def check_version():
|
|
|
388
388
|
# Herramientas Locales Core
|
|
389
389
|
# ==============================================================================
|
|
390
390
|
|
|
391
|
-
def list_directory(path: str = "."
|
|
392
|
-
"""
|
|
391
|
+
def list_directory(path: str = ".") -> str:
|
|
392
|
+
"""Lista los archivos y carpetas de un directorio, sin recorrer subdirectorios."""
|
|
393
393
|
try:
|
|
394
394
|
target = Path(path).expanduser().resolve()
|
|
395
395
|
if not target.exists():
|
|
@@ -544,12 +544,11 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
|
|
|
544
544
|
LOCAL_TOOLS_DEFINITIONS = [
|
|
545
545
|
{
|
|
546
546
|
"name": "list_directory",
|
|
547
|
-
"description": "Lista archivos y carpetas
|
|
547
|
+
"description": "Lista archivos y carpetas de un directorio local, sin recorrer subdirectorios.",
|
|
548
548
|
"inputSchema": {
|
|
549
549
|
"type": "object",
|
|
550
550
|
"properties": {
|
|
551
|
-
"path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"}
|
|
552
|
-
"max_depth": {"type": "integer", "description": "Profundidad máxima", "default": 1}
|
|
551
|
+
"path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"}
|
|
553
552
|
}
|
|
554
553
|
}
|
|
555
554
|
},
|
|
@@ -607,6 +606,54 @@ LOCAL_TOOL_HANDLERS = {
|
|
|
607
606
|
# Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
|
|
608
607
|
# ==============================================================================
|
|
609
608
|
|
|
609
|
+
MAX_HTTP_BODY_BYTES = 1024 * 1024
|
|
610
|
+
MAX_RPC_METHOD_LENGTH = 128
|
|
611
|
+
MAX_TOOL_NAME_LENGTH = 128
|
|
612
|
+
MAX_PATH_LENGTH = 4096
|
|
613
|
+
MAX_COMMAND_LENGTH = 16384
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
|
|
617
|
+
"""Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
|
|
618
|
+
schemas = {
|
|
619
|
+
"list_directory": {"path": (str, MAX_PATH_LENGTH)},
|
|
620
|
+
"read_file": {
|
|
621
|
+
"path": (str, MAX_PATH_LENGTH), "start_line": (int, None),
|
|
622
|
+
"max_lines": (int, None), "max_bytes": (int, None)
|
|
623
|
+
},
|
|
624
|
+
"edit_file": {
|
|
625
|
+
"path": (str, MAX_PATH_LENGTH), "content": (str, MAX_HTTP_BODY_BYTES),
|
|
626
|
+
"mode": (str, 32), "target_content": (str, MAX_HTTP_BODY_BYTES)
|
|
627
|
+
},
|
|
628
|
+
"execute_command": {
|
|
629
|
+
"command": (str, MAX_COMMAND_LENGTH), "cwd": (str, MAX_PATH_LENGTH),
|
|
630
|
+
"timeout_seconds": (int, None)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
schema = schemas.get(tool_name)
|
|
634
|
+
if schema is None:
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
for name, value in arguments.items():
|
|
638
|
+
expected = schema.get(name)
|
|
639
|
+
if expected is None:
|
|
640
|
+
return f"Argumento no permitido: {name}"
|
|
641
|
+
expected_type, max_length = expected
|
|
642
|
+
if isinstance(value, bool) or not isinstance(value, expected_type):
|
|
643
|
+
return f"Tipo inválido para '{name}'"
|
|
644
|
+
if max_length is not None and len(value) > max_length:
|
|
645
|
+
return f"'{name}' excede el tamaño máximo permitido"
|
|
646
|
+
|
|
647
|
+
required = {
|
|
648
|
+
"read_file": ("path",),
|
|
649
|
+
"edit_file": ("path", "content"),
|
|
650
|
+
"execute_command": ("command",)
|
|
651
|
+
}
|
|
652
|
+
for name in required.get(tool_name, ()):
|
|
653
|
+
if name not in arguments:
|
|
654
|
+
return f"Falta el argumento obligatorio: {name}"
|
|
655
|
+
return None
|
|
656
|
+
|
|
610
657
|
def sanitize_log_path(raw_path: str) -> str:
|
|
611
658
|
"""Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
|
|
612
659
|
if not raw_path or "?" not in raw_path:
|
|
@@ -626,10 +673,10 @@ def format_log_error(msg: str, max_len: int = 160) -> str:
|
|
|
626
673
|
return cleaned
|
|
627
674
|
|
|
628
675
|
|
|
629
|
-
def is_allowed_origin(origin: str | None) -> bool:
|
|
676
|
+
def is_allowed_origin(origin: str | None, require_origin: bool = False) -> bool:
|
|
630
677
|
"""Verifica si el origen CORS está autorizado."""
|
|
631
678
|
if origin is None or origin == "null":
|
|
632
|
-
return
|
|
679
|
+
return not require_origin
|
|
633
680
|
origin_lower = origin.lower()
|
|
634
681
|
if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
|
|
635
682
|
return True
|
|
@@ -1379,7 +1426,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1379
1426
|
|
|
1380
1427
|
def send_cors_headers(self):
|
|
1381
1428
|
origin = self.headers.get("Origin")
|
|
1382
|
-
if not is_allowed_origin(origin):
|
|
1429
|
+
if not is_allowed_origin(origin, require_origin=True):
|
|
1383
1430
|
return
|
|
1384
1431
|
self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
|
|
1385
1432
|
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
@@ -1387,19 +1434,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1387
1434
|
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
1388
1435
|
|
|
1389
1436
|
def verify_token(self) -> bool:
|
|
1390
|
-
"""Comprueba el token
|
|
1437
|
+
"""Comprueba el token de sesión exclusivamente en cabeceras HTTP."""
|
|
1391
1438
|
auth_header = self.headers.get("Authorization", "")
|
|
1392
1439
|
token_candidate = None
|
|
1393
1440
|
if auth_header.startswith("Bearer "):
|
|
1394
1441
|
token_candidate = auth_header[7:].strip()
|
|
1395
1442
|
elif "X-ZeroChat-Token" in self.headers:
|
|
1396
1443
|
token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
|
|
1397
|
-
elif "?" in self.path:
|
|
1398
|
-
query = self.path.split("?", 1)[1]
|
|
1399
|
-
for part in query.split("&"):
|
|
1400
|
-
if part.startswith("token="):
|
|
1401
|
-
token_candidate = part.split("=", 1)[1].strip()
|
|
1402
|
-
break
|
|
1403
1444
|
|
|
1404
1445
|
if not token_candidate:
|
|
1405
1446
|
return False
|
|
@@ -1416,7 +1457,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1416
1457
|
if not is_heartbeat:
|
|
1417
1458
|
self._log_req("OPTIONS", safe_path)
|
|
1418
1459
|
origin = self.headers.get("Origin")
|
|
1419
|
-
if not is_allowed_origin(origin):
|
|
1460
|
+
if not is_allowed_origin(origin, require_origin=True):
|
|
1420
1461
|
self.send_response(403)
|
|
1421
1462
|
self.end_headers()
|
|
1422
1463
|
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
@@ -1534,8 +1575,25 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1534
1575
|
self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
|
|
1535
1576
|
return
|
|
1536
1577
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1578
|
+
raw_content_len = self.headers.get("Content-Length")
|
|
1579
|
+
try:
|
|
1580
|
+
content_len = int(raw_content_len)
|
|
1581
|
+
except (TypeError, ValueError):
|
|
1582
|
+
self._log_req("POST", safe_path)
|
|
1583
|
+
self._send_json_response(400, {"error": "Invalid Content-Length"})
|
|
1584
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length inválido")
|
|
1585
|
+
return
|
|
1586
|
+
if content_len < 0:
|
|
1587
|
+
self._log_req("POST", safe_path)
|
|
1588
|
+
self._send_json_response(400, {"error": "Invalid Content-Length"})
|
|
1589
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length negativo")
|
|
1590
|
+
return
|
|
1591
|
+
if content_len > MAX_HTTP_BODY_BYTES:
|
|
1592
|
+
self._log_req("POST", safe_path)
|
|
1593
|
+
self._send_json_response(413, {"error": "Request body too large"})
|
|
1594
|
+
self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
|
|
1595
|
+
return
|
|
1596
|
+
post_data = self.rfile.read(content_len)
|
|
1539
1597
|
|
|
1540
1598
|
try:
|
|
1541
1599
|
req = json.loads(post_data.decode("utf-8"))
|
|
@@ -1554,10 +1612,49 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1554
1612
|
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
|
|
1555
1613
|
return
|
|
1556
1614
|
|
|
1557
|
-
|
|
1615
|
+
if not isinstance(req, dict):
|
|
1616
|
+
self._send_json_response(400, {"error": "Request must be a JSON object"})
|
|
1617
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "La solicitud JSON no es un objeto")
|
|
1618
|
+
return
|
|
1619
|
+
|
|
1558
1620
|
req_id = req.get("id")
|
|
1559
1621
|
method = req.get("method")
|
|
1560
1622
|
params = req.get("params", {})
|
|
1623
|
+
if req.get("jsonrpc") != "2.0":
|
|
1624
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC version"})
|
|
1625
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Versión JSON-RPC inválida")
|
|
1626
|
+
return
|
|
1627
|
+
if req_id is not None and (isinstance(req_id, bool) or not isinstance(req_id, (str, int, float))):
|
|
1628
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC id"})
|
|
1629
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "id JSON-RPC inválido")
|
|
1630
|
+
return
|
|
1631
|
+
if not isinstance(method, str) or not method or len(method) > MAX_RPC_METHOD_LENGTH:
|
|
1632
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC method"})
|
|
1633
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Método JSON-RPC inválido")
|
|
1634
|
+
return
|
|
1635
|
+
if not isinstance(params, dict):
|
|
1636
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC params"})
|
|
1637
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "params JSON-RPC inválidos")
|
|
1638
|
+
return
|
|
1639
|
+
|
|
1640
|
+
if method == "tools/call":
|
|
1641
|
+
tool_name = params.get("name")
|
|
1642
|
+
tool_args = params.get("arguments", {})
|
|
1643
|
+
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > MAX_TOOL_NAME_LENGTH:
|
|
1644
|
+
self._send_json_response(400, {"error": "Invalid tool name"})
|
|
1645
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Nombre de herramienta inválido")
|
|
1646
|
+
return
|
|
1647
|
+
if not isinstance(tool_args, dict):
|
|
1648
|
+
self._send_json_response(400, {"error": "Invalid tool arguments"})
|
|
1649
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Argumentos de herramienta inválidos")
|
|
1650
|
+
return
|
|
1651
|
+
tool_args_error = validate_local_tool_arguments(tool_name, tool_args)
|
|
1652
|
+
if tool_args_error:
|
|
1653
|
+
self._send_json_response(400, {"error": tool_args_error})
|
|
1654
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, tool_args_error)
|
|
1655
|
+
return
|
|
1656
|
+
|
|
1657
|
+
req_path = self.path.split("?", 1)[0].rstrip("/")
|
|
1561
1658
|
|
|
1562
1659
|
# Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
|
|
1563
1660
|
tool_name = params.get("name", "") if isinstance(params, dict) else ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|