zerochat 9.9.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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.9.0
3
+ Version: 9.10.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.9.0"
7
+ version = "9.10.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.9.0
3
+ Version: 9.10.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
@@ -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.9.0"
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 = 30_000
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)
@@ -4453,10 +4453,8 @@ class ZeroChatArgumentParser(argparse.ArgumentParser):
4453
4453
  self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
4454
4454
 
4455
4455
 
4456
- def main():
4457
- global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL, EXIT_ON_CLOSE
4458
- global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
4459
-
4456
+ def build_parser() -> ZeroChatArgumentParser:
4457
+ """Opciones de la línea de órdenes."""
4460
4458
  parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
4461
4459
  # Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
4462
4460
  parser.color = False
@@ -4464,6 +4462,7 @@ def main():
4464
4462
  parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
4465
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)")
4466
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)")
4467
4466
  parser.add_argument("--keep-server", action="store_true", help="Servidor: seguir en marcha al cerrar el navegador (no se combina con --prompt)")
4468
4467
  parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
4469
4468
  parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
@@ -4475,76 +4474,79 @@ def main():
4475
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")
4476
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)")
4477
4476
  parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
4478
- args = parser.parse_args()
4477
+ return parser
4479
4478
 
4480
- # Los avisos no persisten entre ejecuciones del servidor.
4481
- reset_notices()
4482
4479
 
4483
- # Modo ejecución: stdout queda solo para la respuesta final; todo el registro va a stderr.
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."""
4484
4512
  result_out = sys.stdout
4485
- 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):
4486
- parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
4487
- if args.prompt is not None:
4488
- if not args.prompt.strip():
4489
- parser.error("el prompt está vacío")
4490
- if args.no_browser:
4491
- parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
4492
- if args.keep_server:
4493
- parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
4494
- if args.profile is not None and not args.profile.strip():
4495
- parser.error("--profile no puede estar vacío")
4496
- if args.keep_page and args.headless:
4497
- parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
4498
- export_path = None
4499
- if args.export is not None:
4500
- export_path = Path(args.export).expanduser().absolute()
4501
- if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
4502
- parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
4503
- if not export_path.parent.is_dir():
4504
- parser.error(f"--export: no existe el directorio {export_path.parent}")
4505
- RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
4506
- # Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
4507
- # y con --log off se descarta.
4508
4513
  log_enabled = args.log == "on" if args.log else RUN_SESSION is None
4509
4514
  if not log_enabled:
4510
4515
  sys.stdout = open(os.devnull, "w", encoding="utf-8")
4511
4516
  elif RUN_SESSION:
4512
4517
  sys.stdout = sys.stderr
4518
+ return result_out
4513
4519
 
4514
- if args.test:
4515
- print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
4516
- print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
4517
- print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
4518
- print(timestamp_message("TEST all local tools ready."))
4519
- return
4520
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."""
4521
4531
  orphans = cleanup_orphan_jobs()
4522
4532
  if orphans:
4523
4533
  print(timestamp_message(f"Detenidos {orphans} comando(s) que seguían en marcha de una sesión anterior interrumpida."))
4524
-
4525
- # 1. Asegurar el entorno MCP aislado en ambos modos de distribución.
4534
+ # Entorno MCP aislado en ambos modos de distribución.
4526
4535
  if not args.no_venv:
4527
4536
  ensure_virtual_environment()
4528
-
4529
- browser_availability = browser_action_availability()
4530
- if not browser_availability.get("available"):
4537
+ if not browser_action_availability().get("available"):
4531
4538
  add_notice(
4532
4539
  "browser_action desactivada: para usarla, inicia el servicio 'playwright' en Ajustes → MCP "
4533
4540
  "(se instala solo). Requiere Node.js y disponer de Chrome o Edge en el sistema."
4534
4541
  )
4535
-
4536
- # 2. Detectar entorno de desarrollo y resolver URL de destino
4537
- dev_root = get_dev_root()
4538
- static_root = get_static_root()
4539
- is_dev = dev_root is not None
4540
- is_installed = is_installed_runtime()
4541
-
4542
- # 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.
4543
4543
  if not is_dev:
4544
4544
  threading.Thread(target=check_version, daemon=True).start()
4545
4545
 
4546
- ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
4547
- ACTIVE_HOST = args.host
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
4548
4550
  SESSION_TOKEN = get_daily_token()
4549
4551
  # A restart deliberately invalidates every browser-side signing credential.
4550
4552
  TOOL_AUTH_KEY = secrets.token_bytes(32)
@@ -4552,10 +4554,14 @@ def main():
4552
4554
  with TOOL_AUTH_NONCES_LOCK:
4553
4555
  TOOL_AUTH_NONCES = {}
4554
4556
 
4555
- # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4556
- local_ui = static_root is not None and not args.ui_url
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
4557
4563
  try:
4558
- server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4564
+ return ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4559
4565
  except OSError as exc:
4560
4566
  if not RUN_SESSION:
4561
4567
  raise
@@ -4563,42 +4569,42 @@ def main():
4563
4569
  hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
4564
4570
  run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
4565
4571
  sys.exit(RUN_EXIT_SETUP)
4566
- # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4567
- try:
4568
- server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4569
- except OSError as retry_exc:
4570
- run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4571
- sys.exit(RUN_EXIT_SETUP)
4572
- log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4573
- ACTIVE_PORT = server.server_address[1]
4574
- exposure_warning = remote_exposure_warning(ACTIVE_HOST)
4575
- if exposure_warning:
4576
- 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
+
4577
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."""
4578
4585
  if args.ui_url:
4579
4586
  ui_url = args.ui_url
4580
4587
  elif static_root:
4581
4588
  ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
4582
4589
  else:
4583
4590
  ui_url = DEFAULT_UI_URL
4584
-
4585
4591
  browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
4586
4592
  fragment = {'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
4587
4593
  if RUN_SESSION:
4588
4594
  fragment['run'] = RUN_SESSION.run_id
4589
- target_url = f"{ui_url}#{urlencode(fragment)}"
4590
- # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
4591
- exit_on_close = not args.keep_server
4592
- EXIT_ON_CLOSE = exit_on_close
4595
+ return ui_url, f"{ui_url}#{urlencode(fragment)}"
4593
4596
 
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)."""
4594
4600
  print("=" * 64)
4595
4601
  print(f" ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
4596
4602
  print(f" Directorio de trabajo : {Path.cwd()}")
4597
4603
  print(f" Datos y MCP : {get_data_dir()}")
4598
4604
  print(f" Entorno MCP : {get_venv_dir()}")
4599
- if is_dev:
4605
+ if dev_root is not None:
4600
4606
  print(f" Modo de ejecución : Desarrollo local ({dev_root})")
4601
- elif is_installed:
4607
+ elif is_installed_runtime():
4602
4608
  print(" Modo de ejecución : Paquete PyPI (GitHub Pages)")
4603
4609
  else:
4604
4610
  print(" Modo de ejecución : Producción (Web universal)")
@@ -4613,31 +4619,43 @@ def main():
4613
4619
  print(" Auto-cierre : Desactivado")
4614
4620
  print("=" * 64, flush=True)
4615
4621
 
4616
- headless_browser = None
4617
- if RUN_SESSION:
4618
- headless_browser = launch_headless_browser(target_url) if args.headless else None
4619
- if headless_browser is None and not launch_browser(target_url):
4620
- run_error("No se pudo abrir el navegador.")
4621
- server.server_close()
4622
- sys.stdout = result_out
4623
- sys.exit(RUN_EXIT_SETUP)
4624
- if headless_browser is not None:
4625
- threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
4626
- threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
4627
- 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:
4628
4631
  CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
4629
4632
  CONSOLE_CONTROL.start()
4630
4633
  if not args.no_browser:
4631
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()
4632
4655
 
4633
- if exit_on_close:
4634
- require_initial = not args.no_browser
4635
- threading.Thread(
4636
- target=heartbeat_watchdog,
4637
- args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
4638
- daemon=True
4639
- ).start()
4640
4656
 
4657
+ def install_signal_handlers(server: ThreadingHTTPServer):
4658
+ """Ctrl+C, SIGTERM y el cierre del terminal detienen el servidor de forma ordenada."""
4641
4659
  def shutdown(*_):
4642
4660
  console_log("\n" + timestamp_message("Deteniendo servidor ZeroChat..."))
4643
4661
  stop_zerochat_server(server)
@@ -4648,6 +4666,10 @@ def main():
4648
4666
  if hasattr(signal, "SIGHUP"):
4649
4667
  signal.signal(signal.SIGHUP, shutdown)
4650
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
4651
4673
  try:
4652
4674
  server.serve_forever()
4653
4675
  finally:
@@ -4657,16 +4679,58 @@ def main():
4657
4679
  CONSOLE_CONTROL.close()
4658
4680
  CONSOLE_CONTROL = None
4659
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
+
4660
4732
  if RUN_SESSION:
4661
- # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
4662
- if headless_browser is not None and headless_browser.poll() is None:
4663
- _kill_process_tree(headless_browser)
4664
- code, text = run_outcome(RUN_SESSION)
4665
- sys.stdout = result_out
4666
- if text is not None:
4667
- result_out.write(text if text.endswith("\n") else text + "\n")
4668
- result_out.flush()
4669
- sys.exit(code)
4733
+ finish_run(headless_browser, result_out)
4670
4734
 
4671
4735
 
4672
4736
  if __name__ == "__main__":
File without changes
File without changes
File without changes