zerochat 9.8.0__tar.gz → 9.10.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.8.0/zerochat.egg-info → zerochat-9.10.0}/PKG-INFO +1 -1
- {zerochat-9.8.0 → zerochat-9.10.0}/pyproject.toml +1 -1
- {zerochat-9.8.0 → zerochat-9.10.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-9.8.0 → zerochat-9.10.0}/zerochat.py +274 -113
- {zerochat-9.8.0 → zerochat-9.10.0}/LICENSE +0 -0
- {zerochat-9.8.0 → zerochat-9.10.0}/README.md +0 -0
- {zerochat-9.8.0 → zerochat-9.10.0}/setup.cfg +0 -0
- {zerochat-9.8.0 → zerochat-9.10.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-9.8.0 → zerochat-9.10.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-9.8.0 → zerochat-9.10.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-9.8.0 → zerochat-9.10.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -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.10.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."""
|
|
@@ -316,7 +316,7 @@ ACTIVE_HOST = DEFAULT_HOST
|
|
|
316
316
|
# Kept only for this Python process: binds an approved browser tool call to
|
|
317
317
|
# the exact JSON-RPC bytes sent to the local host.
|
|
318
318
|
TOOL_AUTH_VERSION = "zerochat-tool-auth-v1"
|
|
319
|
-
TOOL_AUTH_TTL_MS =
|
|
319
|
+
TOOL_AUTH_TTL_MS = 60_000
|
|
320
320
|
TOOL_AUTH_MAX_NONCES = 10_000
|
|
321
321
|
TOOL_AUTH_KEY = secrets.token_bytes(32)
|
|
322
322
|
TOOL_AUTH_SESSION_ID = secrets.token_urlsafe(18)
|
|
@@ -1344,6 +1344,92 @@ def set_project_directory(path) -> dict:
|
|
|
1344
1344
|
return {"cwd": str(target), "previous_cwd": previous}
|
|
1345
1345
|
|
|
1346
1346
|
|
|
1347
|
+
# Visores gráficos que `git difftool` ya sabe lanzar, en orden de preferencia, con el ejecutable que los delata.
|
|
1348
|
+
GUI_DIFF_TOOLS = {"vscode": "code", "meld": "meld", "kdiff3": "kdiff3", "kompare": "kompare", "gvimdiff": "gvim"}
|
|
1349
|
+
# Respaldo cuando no hay ninguno: visor web que se instala en el entorno aislado de ZeroChat.
|
|
1350
|
+
WEBDIFF_REQUIREMENT = "webdiff==1.4.0"
|
|
1351
|
+
WEBDIFF_INSTALL_TIMEOUT_SECONDS = 120
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _venv_webdiff() -> Path:
|
|
1355
|
+
"""Ruta de webdiff dentro de ~/zerochat/.venv, junto al intérprete del entorno."""
|
|
1356
|
+
venv_python = get_venv_python(get_venv_dir())
|
|
1357
|
+
return venv_python.with_name("webdiff" + venv_python.suffix)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _find_webdiff() -> str:
|
|
1361
|
+
"""webdiff del entorno de ZeroChat o, si no, del PATH; '' si no hay ninguno."""
|
|
1362
|
+
venv_webdiff = _venv_webdiff()
|
|
1363
|
+
if venv_webdiff.exists():
|
|
1364
|
+
return str(venv_webdiff)
|
|
1365
|
+
return shutil.which("webdiff") or ""
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _diff_tool_settings(tool: str, webdiff_bin: str) -> list:
|
|
1369
|
+
"""Pares (clave, valor) de git config que deja `tool` como visor gráfico; webdiff no es un visor integrado de git."""
|
|
1370
|
+
settings = [("diff.guitool", tool)]
|
|
1371
|
+
if tool == "webdiff":
|
|
1372
|
+
settings.insert(0, ("difftool.webdiff.cmd", f'"{webdiff_bin}" "$LOCAL" "$REMOTE"'))
|
|
1373
|
+
return settings
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def detect_gui_diff_tool() -> dict:
|
|
1377
|
+
"""Visor que proponer cuando Git no tiene ninguno: el primero instalado o, si no hay, webdiff.
|
|
1378
|
+
|
|
1379
|
+
Incluye las órdenes exactas que aplicará configure_gui_diff_tool, para mostrarlas antes de confirmar.
|
|
1380
|
+
"""
|
|
1381
|
+
tool = next((name for name, binary in GUI_DIFF_TOOLS.items() if shutil.which(binary)), "webdiff")
|
|
1382
|
+
webdiff_bin = _find_webdiff()
|
|
1383
|
+
return {
|
|
1384
|
+
"tool": tool,
|
|
1385
|
+
"install": tool == "webdiff" and not webdiff_bin,
|
|
1386
|
+
"commands": [
|
|
1387
|
+
shlex.join(["git", "config", "--global", key, value])
|
|
1388
|
+
for key, value in _diff_tool_settings(tool, webdiff_bin or str(_venv_webdiff()))
|
|
1389
|
+
],
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _install_webdiff() -> str:
|
|
1394
|
+
"""Instala webdiff en ~/zerochat/.venv y devuelve la ruta del ejecutable."""
|
|
1395
|
+
ensure_virtual_environment()
|
|
1396
|
+
venv_python = get_venv_python(get_venv_dir())
|
|
1397
|
+
if not venv_python.exists():
|
|
1398
|
+
raise RuntimeError(f"No se pudo preparar el entorno virtual de ZeroChat: falta {venv_python}")
|
|
1399
|
+
res = subprocess.run(
|
|
1400
|
+
[str(venv_python), "-m", "pip", "install", WEBDIFF_REQUIREMENT],
|
|
1401
|
+
capture_output=True,
|
|
1402
|
+
text=True,
|
|
1403
|
+
timeout=WEBDIFF_INSTALL_TIMEOUT_SECONDS,
|
|
1404
|
+
check=False
|
|
1405
|
+
)
|
|
1406
|
+
if res.returncode != 0:
|
|
1407
|
+
raise RuntimeError(f"Error instalando webdiff: {(res.stderr or res.stdout).strip()[-500:]}")
|
|
1408
|
+
webdiff = _venv_webdiff()
|
|
1409
|
+
if not webdiff.exists():
|
|
1410
|
+
raise RuntimeError(f"webdiff se instaló pero no aparece en {webdiff}")
|
|
1411
|
+
return str(webdiff)
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def configure_gui_diff_tool(tool) -> dict:
|
|
1415
|
+
"""Deja `tool` como visor gráfico global de Git, instalando antes webdiff si es el elegido y falta."""
|
|
1416
|
+
if not isinstance(tool, str) or (tool != "webdiff" and tool not in GUI_DIFF_TOOLS):
|
|
1417
|
+
raise ValueError(f"Visor de diffs '{tool}' no reconocido.")
|
|
1418
|
+
git_bin = shutil.which("git")
|
|
1419
|
+
if not git_bin:
|
|
1420
|
+
raise RuntimeError("Git no está instalado o no se encuentra en el PATH del sistema.")
|
|
1421
|
+
|
|
1422
|
+
webdiff_bin = ""
|
|
1423
|
+
if tool == "webdiff":
|
|
1424
|
+
webdiff_bin = _find_webdiff() or _install_webdiff()
|
|
1425
|
+
elif not shutil.which(GUI_DIFF_TOOLS[tool]):
|
|
1426
|
+
raise ValueError(f"'{GUI_DIFF_TOOLS[tool]}' no está en el PATH del sistema.")
|
|
1427
|
+
|
|
1428
|
+
for key, value in _diff_tool_settings(tool, webdiff_bin):
|
|
1429
|
+
subprocess.run([git_bin, "config", "--global", key, value], capture_output=True, timeout=5, check=True)
|
|
1430
|
+
return {"tool": tool}
|
|
1431
|
+
|
|
1432
|
+
|
|
1347
1433
|
def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
|
|
1348
1434
|
"""Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
|
|
1349
1435
|
|
|
@@ -1365,7 +1451,10 @@ def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
|
|
|
1365
1451
|
|
|
1366
1452
|
|
|
1367
1453
|
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.
|
|
1454
|
+
"""Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío.
|
|
1455
|
+
|
|
1456
|
+
Sin visor gráfico configurado no lanza nada y devuelve en `setup` el que propone configurar.
|
|
1457
|
+
"""
|
|
1369
1458
|
if not isinstance(path, str) or not path.strip():
|
|
1370
1459
|
raise ValueError("La ruta del archivo está vacía.")
|
|
1371
1460
|
if len(path) > MAX_PATH_LENGTH:
|
|
@@ -1398,10 +1487,7 @@ def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
|
|
|
1398
1487
|
|
|
1399
1488
|
repo_root = Path(res.stdout.strip()).resolve()
|
|
1400
1489
|
if not _configured_gui_diff_tool(git_bin, repo_root):
|
|
1401
|
-
|
|
1402
|
-
"No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
|
|
1403
|
-
"git config --global diff.guitool meld"
|
|
1404
|
-
)
|
|
1490
|
+
return {"success": False, "setup": detect_gui_diff_tool()}
|
|
1405
1491
|
|
|
1406
1492
|
has_head = subprocess.run(
|
|
1407
1493
|
[git_bin, "rev-parse", "--verify", "HEAD"],
|
|
@@ -2218,18 +2304,22 @@ def playwright_launch_env(config: dict) -> dict[str, str]:
|
|
|
2218
2304
|
|
|
2219
2305
|
|
|
2220
2306
|
def validate_profile_browser(raw) -> dict:
|
|
2221
|
-
"""Navegador que exige un perfil: el propio con un motor
|
|
2222
|
-
|
|
2223
|
-
|
|
2307
|
+
"""Navegador que exige un perfil: el propio con un motor, y headless False si exige la ventana
|
|
2308
|
+
visible, o el del usuario con uno de sus navegadores."""
|
|
2309
|
+
if isinstance(raw, dict) and raw.get("mode") == "own" and raw.get("engine") in OWN_BROWSER_ENGINES:
|
|
2310
|
+
if set(raw) == {"mode", "engine"}:
|
|
2311
|
+
return {"mode": "own", "engine": raw["engine"]}
|
|
2312
|
+
if set(raw) == {"mode", "engine", "headless"} and raw["headless"] is False:
|
|
2313
|
+
return {"mode": "own", "engine": raw["engine"], "headless": False}
|
|
2224
2314
|
if isinstance(raw, dict) and raw.get("mode") == "user" and set(raw) == {"mode", "userBrowser"} and raw["userBrowser"] in USER_BROWSERS:
|
|
2225
2315
|
return {"mode": "user", "userBrowser": raw["userBrowser"]}
|
|
2226
|
-
raise ValueError("browser debe ser {mode: own, engine} o {mode: user, userBrowser} con un motor o navegador admitido")
|
|
2316
|
+
raise ValueError("browser debe ser {mode: own, engine[, headless: false]} o {mode: user, userBrowser} con un motor o navegador admitido")
|
|
2227
2317
|
|
|
2228
2318
|
|
|
2229
2319
|
def browser_choice(config: dict) -> dict:
|
|
2230
2320
|
"""Modo y navegador con los que arranca Playwright MCP, en la forma de validate_profile_browser."""
|
|
2231
2321
|
if config["mode"] == "own":
|
|
2232
|
-
return {"mode": "own", "engine": config["engine"]}
|
|
2322
|
+
return {"mode": "own", "engine": config["engine"], **({} if config["headless"] else {"headless": False})}
|
|
2233
2323
|
return {"mode": "user", "userBrowser": config["userBrowser"]}
|
|
2234
2324
|
|
|
2235
2325
|
|
|
@@ -3774,6 +3864,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3774
3864
|
return None, {"code": -32602, "message": str(exc)}, ""
|
|
3775
3865
|
except Exception as exc:
|
|
3776
3866
|
return None, {"code": -32603, "message": f"Error al abrir el visor de diffs: {exc}"}, str(exc)
|
|
3867
|
+
if method == "zerochat/diff/configure":
|
|
3868
|
+
try:
|
|
3869
|
+
return configure_gui_diff_tool(params.get("tool")), None, ""
|
|
3870
|
+
except (ValueError, RuntimeError) as exc:
|
|
3871
|
+
return None, {"code": -32602, "message": str(exc)}, ""
|
|
3872
|
+
except Exception as exc:
|
|
3873
|
+
return None, {"code": -32603, "message": f"Error configurando el visor de diffs: {exc}"}, str(exc)
|
|
3777
3874
|
if method == "zerochat/web/fetch":
|
|
3778
3875
|
try:
|
|
3779
3876
|
return fetch_web_resource(params), None, ""
|
|
@@ -4356,10 +4453,8 @@ class ZeroChatArgumentParser(argparse.ArgumentParser):
|
|
|
4356
4453
|
self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
|
|
4357
4454
|
|
|
4358
4455
|
|
|
4359
|
-
def
|
|
4360
|
-
|
|
4361
|
-
global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
|
|
4362
|
-
|
|
4456
|
+
def build_parser() -> ZeroChatArgumentParser:
|
|
4457
|
+
"""Opciones de la línea de órdenes."""
|
|
4363
4458
|
parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
|
|
4364
4459
|
# Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
|
|
4365
4460
|
parser.color = False
|
|
@@ -4367,6 +4462,7 @@ def main():
|
|
|
4367
4462
|
parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
|
|
4368
4463
|
parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
|
|
4369
4464
|
parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
|
|
4465
|
+
parser.add_argument("--print-url", action="store_true", help="Imprimir la URL de la interfaz con el token, entre líneas en blanco para copiarla (implica --no-browser)")
|
|
4370
4466
|
parser.add_argument("--keep-server", action="store_true", help="Servidor: seguir en marcha al cerrar el navegador (no se combina con --prompt)")
|
|
4371
4467
|
parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
|
|
4372
4468
|
parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
|
|
@@ -4378,76 +4474,79 @@ def main():
|
|
|
4378
4474
|
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")
|
|
4379
4475
|
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)")
|
|
4380
4476
|
parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
|
|
4381
|
-
|
|
4477
|
+
return parser
|
|
4382
4478
|
|
|
4383
|
-
# Los avisos no persisten entre ejecuciones del servidor.
|
|
4384
|
-
reset_notices()
|
|
4385
4479
|
|
|
4386
|
-
|
|
4480
|
+
def run_session_from_args(parser: ZeroChatArgumentParser, args) -> RunSession | None:
|
|
4481
|
+
"""Valida las combinaciones de opciones y devuelve la sesión de --prompt, si la hay."""
|
|
4482
|
+
# Quien pide la URL la abrirá en otro sitio (otro equipo, un túnel SSH): no se abre el navegador local.
|
|
4483
|
+
if args.print_url and args.prompt is None:
|
|
4484
|
+
args.no_browser = True
|
|
4485
|
+
if args.prompt is None:
|
|
4486
|
+
if args.headless or args.keep_page or args.allow_dangerous_tools or args.profile is not None or args.export is not None:
|
|
4487
|
+
parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
|
|
4488
|
+
return None
|
|
4489
|
+
if not args.prompt.strip():
|
|
4490
|
+
parser.error("el prompt está vacío")
|
|
4491
|
+
if args.no_browser or args.print_url:
|
|
4492
|
+
parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser ni con --print-url")
|
|
4493
|
+
if args.keep_server:
|
|
4494
|
+
parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
|
|
4495
|
+
if args.profile is not None and not args.profile.strip():
|
|
4496
|
+
parser.error("--profile no puede estar vacío")
|
|
4497
|
+
if args.keep_page and args.headless:
|
|
4498
|
+
parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
|
|
4499
|
+
export_path = None
|
|
4500
|
+
if args.export is not None:
|
|
4501
|
+
export_path = Path(args.export).expanduser().absolute()
|
|
4502
|
+
if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
|
|
4503
|
+
parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
|
|
4504
|
+
if not export_path.parent.is_dir():
|
|
4505
|
+
parser.error(f"--export: no existe el directorio {export_path.parent}")
|
|
4506
|
+
return RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
|
|
4507
|
+
|
|
4508
|
+
|
|
4509
|
+
def redirect_log(args):
|
|
4510
|
+
"""Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
|
|
4511
|
+
y con --log off se descarta. Devuelve el stdout original, el de la respuesta."""
|
|
4387
4512
|
result_out = sys.stdout
|
|
4388
|
-
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):
|
|
4389
|
-
parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
|
|
4390
|
-
if args.prompt is not None:
|
|
4391
|
-
if not args.prompt.strip():
|
|
4392
|
-
parser.error("el prompt está vacío")
|
|
4393
|
-
if args.no_browser:
|
|
4394
|
-
parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
|
|
4395
|
-
if args.keep_server:
|
|
4396
|
-
parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
|
|
4397
|
-
if args.profile is not None and not args.profile.strip():
|
|
4398
|
-
parser.error("--profile no puede estar vacío")
|
|
4399
|
-
if args.keep_page and args.headless:
|
|
4400
|
-
parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
|
|
4401
|
-
export_path = None
|
|
4402
|
-
if args.export is not None:
|
|
4403
|
-
export_path = Path(args.export).expanduser().absolute()
|
|
4404
|
-
if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
|
|
4405
|
-
parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
|
|
4406
|
-
if not export_path.parent.is_dir():
|
|
4407
|
-
parser.error(f"--export: no existe el directorio {export_path.parent}")
|
|
4408
|
-
RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
|
|
4409
|
-
# Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
|
|
4410
|
-
# y con --log off se descarta.
|
|
4411
4513
|
log_enabled = args.log == "on" if args.log else RUN_SESSION is None
|
|
4412
4514
|
if not log_enabled:
|
|
4413
4515
|
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
|
4414
4516
|
elif RUN_SESSION:
|
|
4415
4517
|
sys.stdout = sys.stderr
|
|
4518
|
+
return result_out
|
|
4416
4519
|
|
|
4417
|
-
if args.test:
|
|
4418
|
-
print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
|
|
4419
|
-
print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
|
|
4420
|
-
print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
|
|
4421
|
-
print(timestamp_message("TEST all local tools ready."))
|
|
4422
|
-
return
|
|
4423
4520
|
|
|
4521
|
+
def run_self_test():
|
|
4522
|
+
"""--test: comprueba las herramientas locales básicas sin arrancar el servidor."""
|
|
4523
|
+
print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
|
|
4524
|
+
print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
|
|
4525
|
+
print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
|
|
4526
|
+
print(timestamp_message("TEST all local tools ready."))
|
|
4527
|
+
|
|
4528
|
+
|
|
4529
|
+
def prepare_environment(args, is_dev: bool):
|
|
4530
|
+
"""Limpia restos de una sesión anterior, prepara el entorno MCP y lanza las comprobaciones de fondo."""
|
|
4424
4531
|
orphans = cleanup_orphan_jobs()
|
|
4425
4532
|
if orphans:
|
|
4426
4533
|
print(timestamp_message(f"Detenidos {orphans} comando(s) que seguían en marcha de una sesión anterior interrumpida."))
|
|
4427
|
-
|
|
4428
|
-
# 1. Asegurar el entorno MCP aislado en ambos modos de distribución.
|
|
4534
|
+
# Entorno MCP aislado en ambos modos de distribución.
|
|
4429
4535
|
if not args.no_venv:
|
|
4430
4536
|
ensure_virtual_environment()
|
|
4431
|
-
|
|
4432
|
-
browser_availability = browser_action_availability()
|
|
4433
|
-
if not browser_availability.get("available"):
|
|
4537
|
+
if not browser_action_availability().get("available"):
|
|
4434
4538
|
add_notice(
|
|
4435
4539
|
"browser_action desactivada: para usarla, inicia el servicio 'playwright' en Ajustes → MCP "
|
|
4436
4540
|
"(se instala solo). Requiere Node.js y disponer de Chrome o Edge en el sistema."
|
|
4437
4541
|
)
|
|
4438
|
-
|
|
4439
|
-
# 2. Detectar entorno de desarrollo y resolver URL de destino
|
|
4440
|
-
dev_root = get_dev_root()
|
|
4441
|
-
static_root = get_static_root()
|
|
4442
|
-
is_dev = dev_root is not None
|
|
4443
|
-
is_installed = is_installed_runtime()
|
|
4444
|
-
|
|
4445
|
-
# 3. Comprobar versión remota en segundo plano (solo fuera del entorno de desarrollo local)
|
|
4542
|
+
# Versión remota en segundo plano, solo fuera del entorno de desarrollo local.
|
|
4446
4543
|
if not is_dev:
|
|
4447
4544
|
threading.Thread(target=check_version, daemon=True).start()
|
|
4448
4545
|
|
|
4449
|
-
|
|
4450
|
-
|
|
4546
|
+
|
|
4547
|
+
def reset_session_credentials():
|
|
4548
|
+
"""Carga el token diario y renueva la clave de firma de herramientas."""
|
|
4549
|
+
global SESSION_TOKEN, TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
|
|
4451
4550
|
SESSION_TOKEN = get_daily_token()
|
|
4452
4551
|
# A restart deliberately invalidates every browser-side signing credential.
|
|
4453
4552
|
TOOL_AUTH_KEY = secrets.token_bytes(32)
|
|
@@ -4455,10 +4554,14 @@ def main():
|
|
|
4455
4554
|
with TOOL_AUTH_NONCES_LOCK:
|
|
4456
4555
|
TOOL_AUTH_NONCES = {}
|
|
4457
4556
|
|
|
4458
|
-
|
|
4459
|
-
|
|
4557
|
+
|
|
4558
|
+
def open_server(args, local_ui: bool) -> ThreadingHTTPServer:
|
|
4559
|
+
"""Abre el puerto de escucha; con --prompt, si el puerto por defecto está ocupado, usa uno libre."""
|
|
4560
|
+
global ACTIVE_PORT, ACTIVE_HOST
|
|
4561
|
+
ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
|
|
4562
|
+
ACTIVE_HOST = args.host
|
|
4460
4563
|
try:
|
|
4461
|
-
|
|
4564
|
+
return ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
|
|
4462
4565
|
except OSError as exc:
|
|
4463
4566
|
if not RUN_SESSION:
|
|
4464
4567
|
raise
|
|
@@ -4466,42 +4569,42 @@ def main():
|
|
|
4466
4569
|
hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
|
|
4467
4570
|
run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
|
|
4468
4571
|
sys.exit(RUN_EXIT_SETUP)
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
add_notice(exposure_warning)
|
|
4572
|
+
# Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
|
|
4573
|
+
try:
|
|
4574
|
+
server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
|
|
4575
|
+
except OSError as retry_exc:
|
|
4576
|
+
run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
|
|
4577
|
+
sys.exit(RUN_EXIT_SETUP)
|
|
4578
|
+
log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
|
|
4579
|
+
ACTIVE_PORT = server.server_address[1]
|
|
4580
|
+
return server
|
|
4581
|
+
|
|
4480
4582
|
|
|
4583
|
+
def interface_urls(args, static_root) -> tuple[str, str]:
|
|
4584
|
+
"""Devuelve la URL de la interfaz y la de destino, con el token y el servidor en el fragmento."""
|
|
4481
4585
|
if args.ui_url:
|
|
4482
4586
|
ui_url = args.ui_url
|
|
4483
4587
|
elif static_root:
|
|
4484
4588
|
ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
|
|
4485
4589
|
else:
|
|
4486
4590
|
ui_url = DEFAULT_UI_URL
|
|
4487
|
-
|
|
4488
4591
|
browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
|
|
4489
4592
|
fragment = {'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
|
|
4490
4593
|
if RUN_SESSION:
|
|
4491
4594
|
fragment['run'] = RUN_SESSION.run_id
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
exit_on_close = not args.keep_server
|
|
4495
|
-
EXIT_ON_CLOSE = exit_on_close
|
|
4595
|
+
return ui_url, f"{ui_url}#{urlencode(fragment)}"
|
|
4596
|
+
|
|
4496
4597
|
|
|
4598
|
+
def print_header(dev_root, ui_url: str, exposure_warning: str | None, exit_on_close: bool):
|
|
4599
|
+
"""Cabecera del servidor con su configuración (sin el token)."""
|
|
4497
4600
|
print("=" * 64)
|
|
4498
4601
|
print(f" ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
|
|
4499
4602
|
print(f" Directorio de trabajo : {Path.cwd()}")
|
|
4500
4603
|
print(f" Datos y MCP : {get_data_dir()}")
|
|
4501
4604
|
print(f" Entorno MCP : {get_venv_dir()}")
|
|
4502
|
-
if
|
|
4605
|
+
if dev_root is not None:
|
|
4503
4606
|
print(f" Modo de ejecución : Desarrollo local ({dev_root})")
|
|
4504
|
-
elif
|
|
4607
|
+
elif is_installed_runtime():
|
|
4505
4608
|
print(" Modo de ejecución : Paquete PyPI (GitHub Pages)")
|
|
4506
4609
|
else:
|
|
4507
4610
|
print(" Modo de ejecución : Producción (Web universal)")
|
|
@@ -4516,31 +4619,43 @@ def main():
|
|
|
4516
4619
|
print(" Auto-cierre : Desactivado")
|
|
4517
4620
|
print("=" * 64, flush=True)
|
|
4518
4621
|
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
|
|
4529
|
-
threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
|
|
4530
|
-
else:
|
|
4622
|
+
|
|
4623
|
+
def start_interface(args, parser, server: ThreadingHTTPServer, target_url: str, result_out):
|
|
4624
|
+
"""Abre la interfaz: en modo ejecución, con su supervisor; si no, con el control de consola.
|
|
4625
|
+
Devuelve el navegador sin ventana de --headless, si se lanzó."""
|
|
4626
|
+
global CONSOLE_CONTROL
|
|
4627
|
+
if args.print_url:
|
|
4628
|
+
# Líneas en blanco alrededor para seleccionarla y copiarla con facilidad (p. ej. en una consola SSH del móvil).
|
|
4629
|
+
print("\n" * 5 + target_url + "\n" * 5, flush=True)
|
|
4630
|
+
if not RUN_SESSION:
|
|
4531
4631
|
CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
|
|
4532
4632
|
CONSOLE_CONTROL.start()
|
|
4533
4633
|
if not args.no_browser:
|
|
4534
4634
|
launch_browser(target_url)
|
|
4635
|
+
return None
|
|
4636
|
+
headless_browser = launch_headless_browser(target_url) if args.headless else None
|
|
4637
|
+
if headless_browser is None and not launch_browser(target_url):
|
|
4638
|
+
run_error("No se pudo abrir el navegador.")
|
|
4639
|
+
server.server_close()
|
|
4640
|
+
sys.stdout = result_out
|
|
4641
|
+
sys.exit(RUN_EXIT_SETUP)
|
|
4642
|
+
if headless_browser is not None:
|
|
4643
|
+
threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
|
|
4644
|
+
threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
|
|
4645
|
+
return headless_browser
|
|
4646
|
+
|
|
4647
|
+
|
|
4648
|
+
def start_heartbeat_watchdog(server: ThreadingHTTPServer, require_initial: bool):
|
|
4649
|
+
"""Detiene el servidor cuando el navegador deja de enviar latidos."""
|
|
4650
|
+
threading.Thread(
|
|
4651
|
+
target=heartbeat_watchdog,
|
|
4652
|
+
args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
|
|
4653
|
+
daemon=True
|
|
4654
|
+
).start()
|
|
4535
4655
|
|
|
4536
|
-
if exit_on_close:
|
|
4537
|
-
require_initial = not args.no_browser
|
|
4538
|
-
threading.Thread(
|
|
4539
|
-
target=heartbeat_watchdog,
|
|
4540
|
-
args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
|
|
4541
|
-
daemon=True
|
|
4542
|
-
).start()
|
|
4543
4656
|
|
|
4657
|
+
def install_signal_handlers(server: ThreadingHTTPServer):
|
|
4658
|
+
"""Ctrl+C, SIGTERM y el cierre del terminal detienen el servidor de forma ordenada."""
|
|
4544
4659
|
def shutdown(*_):
|
|
4545
4660
|
console_log("\n" + timestamp_message("Deteniendo servidor ZeroChat..."))
|
|
4546
4661
|
stop_zerochat_server(server)
|
|
@@ -4551,6 +4666,10 @@ def main():
|
|
|
4551
4666
|
if hasattr(signal, "SIGHUP"):
|
|
4552
4667
|
signal.signal(signal.SIGHUP, shutdown)
|
|
4553
4668
|
|
|
4669
|
+
|
|
4670
|
+
def serve(server: ThreadingHTTPServer):
|
|
4671
|
+
"""Atiende peticiones hasta la parada y libera después el servidor y la consola."""
|
|
4672
|
+
global CONSOLE_CONTROL
|
|
4554
4673
|
try:
|
|
4555
4674
|
server.serve_forever()
|
|
4556
4675
|
finally:
|
|
@@ -4560,16 +4679,58 @@ def main():
|
|
|
4560
4679
|
CONSOLE_CONTROL.close()
|
|
4561
4680
|
CONSOLE_CONTROL = None
|
|
4562
4681
|
|
|
4682
|
+
|
|
4683
|
+
def finish_run(headless_browser, result_out):
|
|
4684
|
+
"""Modo ejecución: escribe la respuesta final en el stdout original y sale con su código."""
|
|
4685
|
+
# Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
|
|
4686
|
+
if headless_browser is not None and headless_browser.poll() is None:
|
|
4687
|
+
_kill_process_tree(headless_browser)
|
|
4688
|
+
code, text = run_outcome(RUN_SESSION)
|
|
4689
|
+
sys.stdout = result_out
|
|
4690
|
+
if text is not None:
|
|
4691
|
+
result_out.write(text if text.endswith("\n") else text + "\n")
|
|
4692
|
+
result_out.flush()
|
|
4693
|
+
sys.exit(code)
|
|
4694
|
+
|
|
4695
|
+
|
|
4696
|
+
def main():
|
|
4697
|
+
global RUN_SESSION, EXIT_ON_CLOSE
|
|
4698
|
+
|
|
4699
|
+
parser = build_parser()
|
|
4700
|
+
args = parser.parse_args()
|
|
4701
|
+
# Los avisos no persisten entre ejecuciones del servidor.
|
|
4702
|
+
reset_notices()
|
|
4703
|
+
RUN_SESSION = run_session_from_args(parser, args)
|
|
4704
|
+
result_out = redirect_log(args)
|
|
4705
|
+
|
|
4706
|
+
if args.test:
|
|
4707
|
+
run_self_test()
|
|
4708
|
+
return
|
|
4709
|
+
|
|
4710
|
+
dev_root = get_dev_root()
|
|
4711
|
+
static_root = get_static_root()
|
|
4712
|
+
prepare_environment(args, is_dev=dev_root is not None)
|
|
4713
|
+
reset_session_credentials()
|
|
4714
|
+
|
|
4715
|
+
# La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
|
|
4716
|
+
server = open_server(args, local_ui=static_root is not None and not args.ui_url)
|
|
4717
|
+
exposure_warning = remote_exposure_warning(ACTIVE_HOST)
|
|
4718
|
+
if exposure_warning:
|
|
4719
|
+
add_notice(exposure_warning)
|
|
4720
|
+
|
|
4721
|
+
ui_url, target_url = interface_urls(args, static_root)
|
|
4722
|
+
# El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
|
|
4723
|
+
EXIT_ON_CLOSE = not args.keep_server
|
|
4724
|
+
print_header(dev_root, ui_url, exposure_warning, EXIT_ON_CLOSE)
|
|
4725
|
+
|
|
4726
|
+
headless_browser = start_interface(args, parser, server, target_url, result_out)
|
|
4727
|
+
if EXIT_ON_CLOSE:
|
|
4728
|
+
start_heartbeat_watchdog(server, require_initial=not args.no_browser)
|
|
4729
|
+
install_signal_handlers(server)
|
|
4730
|
+
serve(server)
|
|
4731
|
+
|
|
4563
4732
|
if RUN_SESSION:
|
|
4564
|
-
|
|
4565
|
-
if headless_browser is not None and headless_browser.poll() is None:
|
|
4566
|
-
_kill_process_tree(headless_browser)
|
|
4567
|
-
code, text = run_outcome(RUN_SESSION)
|
|
4568
|
-
sys.stdout = result_out
|
|
4569
|
-
if text is not None:
|
|
4570
|
-
result_out.write(text if text.endswith("\n") else text + "\n")
|
|
4571
|
-
result_out.flush()
|
|
4572
|
-
sys.exit(code)
|
|
4733
|
+
finish_run(headless_browser, result_out)
|
|
4573
4734
|
|
|
4574
4735
|
|
|
4575
4736
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|