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.
- {zerochat-9.4.0/zerochat.egg-info → zerochat-9.6.0}/PKG-INFO +1 -1
- {zerochat-9.4.0 → zerochat-9.6.0}/pyproject.toml +1 -1
- {zerochat-9.4.0 → zerochat-9.6.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-9.4.0 → zerochat-9.6.0}/zerochat.py +462 -149
- {zerochat-9.4.0 → zerochat-9.6.0}/LICENSE +0 -0
- {zerochat-9.4.0 → zerochat-9.6.0}/README.md +0 -0
- {zerochat-9.4.0 → zerochat-9.6.0}/setup.cfg +0 -0
- {zerochat-9.4.0 → zerochat-9.6.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-9.4.0 → zerochat-9.6.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-9.4.0 → zerochat-9.6.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-9.4.0 → zerochat-9.6.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
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
|
|
2870
|
-
if clean_host in METADATA_HOSTS:
|
|
2904
|
+
if clean_host.endswith((".localhost", ".local", ".internal")):
|
|
2871
2905
|
return True
|
|
2872
|
-
|
|
2873
2906
|
try:
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2932
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
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
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
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
|
|
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
|
-
|
|
3281
|
+
body_limit = http_body_limit()
|
|
3282
|
+
if content_len > body_limit:
|
|
3287
3283
|
self._log_req("POST", safe_path)
|
|
3288
|
-
if content_len <=
|
|
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
|
-
|
|
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 =
|
|
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=
|
|
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("--
|
|
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
|
-
|
|
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
|
-
|
|
3829
|
-
|
|
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
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|