zerochat 7.11.0__tar.gz → 7.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 7.11.0
3
+ Version: 7.12.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 = "7.11.0"
7
+ version = "7.12.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: 7.11.0
3
+ Version: 7.12.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
@@ -14,8 +14,10 @@ from __future__ import annotations
14
14
  import argparse
15
15
  import ast
16
16
  import atexit
17
+ import base64
17
18
  import datetime
18
19
  import fnmatch
20
+ import hashlib
19
21
  import hmac
20
22
  import importlib.metadata
21
23
  import json
@@ -40,7 +42,7 @@ import webbrowser
40
42
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
41
43
  from pathlib import Path
42
44
 
43
- SOURCE_BACKEND_VERSION = "7.11.0"
45
+ SOURCE_BACKEND_VERSION = "7.12.0"
44
46
 
45
47
  def _read_source_version(filename: str) -> str | None:
46
48
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -96,7 +98,8 @@ PYPI_VERSION_URL = "https://pypi.org/pypi/zerochat/json"
96
98
  REMOTE_SCRIPT_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/zerochat.py"
97
99
  CONSOLE_STATUS_IDLE_SECONDS = 8.0
98
100
  CONSOLE_CONTROL = None
99
-
101
+ NOTICES: list[str] = []
102
+ NOTICES_LOCK = threading.Lock()
100
103
 
101
104
  def format_uptime(seconds: float) -> str:
102
105
  """Devuelve una duración breve y estable para la línea de estado de consola."""
@@ -191,10 +194,27 @@ class ConsoleControl:
191
194
  if self.status_visible:
192
195
  sys.stdout.write("\r\033[2K")
193
196
  self.status_visible = False
194
- print("\nComandos de consola: [h] ayuda · [n] Navegador · [x] salir ordenadamente\n", flush=True)
197
+ commands = "[h] ayuda · [n] Navegador"
198
+ if get_notices():
199
+ commands += " · [i] información"
200
+ print(f"\nComandos de consola: {commands} · [x] salir ordenadamente\n", flush=True)
195
201
  print(self.parser.format_help().rstrip(), flush=True)
196
202
  self.last_activity = time.monotonic()
197
203
 
204
+ def show_notices(self):
205
+ notices = get_notices()
206
+ if not notices:
207
+ return
208
+ with self.lock:
209
+ if self.status_visible:
210
+ sys.stdout.write("\r\033[2K")
211
+ self.status_visible = False
212
+ print("\nInformación:", flush=True)
213
+ for notice in notices:
214
+ print(f"- {notice}", flush=True)
215
+ print(flush=True)
216
+ self.last_activity = time.monotonic()
217
+
198
218
  def _render_status(self):
199
219
  if not self.enabled:
200
220
  return
@@ -202,7 +222,10 @@ class ConsoleControl:
202
222
  with self.lock:
203
223
  if time.monotonic() - self.last_activity < CONSOLE_STATUS_IDLE_SECONDS:
204
224
  return
205
- sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [n] Navegador · [x] salir")
225
+ commands = "[h] ayuda · [n] Navegador"
226
+ if get_notices():
227
+ commands += " · [i] información"
228
+ sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · {commands} · [x] salir")
206
229
  sys.stdout.flush()
207
230
  self.status_visible = True
208
231
 
@@ -216,6 +239,8 @@ class ConsoleControl:
216
239
  elif key.lower() == "n":
217
240
  if self.target_url:
218
241
  launch_browser(self.target_url)
242
+ elif key.lower() == "i":
243
+ self.show_notices()
219
244
  elif key.lower() == "x":
220
245
  self.log(f"[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
221
246
  stop_zerochat_server(self.server)
@@ -282,6 +307,26 @@ def get_venv_dir() -> Path:
282
307
  return get_data_dir() / ".venv"
283
308
 
284
309
 
310
+ def reset_notices():
311
+ """Reinicia los avisos transitorios de la ejecución actual."""
312
+ with NOTICES_LOCK:
313
+ NOTICES.clear()
314
+
315
+
316
+ def add_notice(message: str):
317
+ """Añade un aviso para la consola interactiva de la ejecución actual."""
318
+ if not isinstance(message, str) or not message.strip():
319
+ raise ValueError("El aviso debe ser texto no vacío")
320
+ with NOTICES_LOCK:
321
+ NOTICES.append(message.strip())
322
+
323
+
324
+ def get_notices() -> tuple[str, ...]:
325
+ """Devuelve una instantánea inmutable de los avisos actuales."""
326
+ with NOTICES_LOCK:
327
+ return tuple(NOTICES)
328
+
329
+
285
330
  def get_daily_token() -> str:
286
331
  """Devuelve un token de sesión diario persistido en ~/zerochat/config/token.json."""
287
332
  config_dir = get_data_dir() / "config"
@@ -310,6 +355,16 @@ SESSION_TOKEN = get_daily_token()
310
355
  ACTIVE_PORT = DEFAULT_PORT
311
356
  ACTIVE_HOST = DEFAULT_HOST
312
357
 
358
+ # Kept only for this Python process: binds an approved browser tool call to
359
+ # the exact JSON-RPC bytes sent to the local host.
360
+ TOOL_AUTH_VERSION = "zerochat-tool-auth-v1"
361
+ TOOL_AUTH_TTL_MS = 30_000
362
+ TOOL_AUTH_MAX_NONCES = 10_000
363
+ TOOL_AUTH_KEY = secrets.token_bytes(32)
364
+ TOOL_AUTH_SESSION_ID = secrets.token_urlsafe(18)
365
+ TOOL_AUTH_NONCES: dict[str, int] = {}
366
+ TOOL_AUTH_NONCES_LOCK = threading.Lock()
367
+
313
368
  DETECTED_OS = "windows" if sys.platform.startswith("win") else ("android" if "ANDROID_ROOT" in os.environ else "linux")
314
369
 
315
370
 
@@ -381,16 +436,15 @@ def check_version():
381
436
  installed = is_installed_runtime()
382
437
  remote_ver = _read_remote_version(PYPI_VERSION_URL if installed else REMOTE_VERSION_URL)
383
438
  if remote_ver and re.match(r"^\d+(\.\d+)+", remote_ver) and has_new_backend_version(remote_ver):
384
- console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Nueva versión del servidor disponible (Local: {VERSION}, Remota: {compatibility_version(remote_ver)})", flush=True)
439
+ notice = f"Nueva versión del servidor disponible (local: {VERSION}, remota: {compatibility_version(remote_ver)})."
385
440
  if installed:
386
- console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Actualiza cuando quieras con: {sys.executable} -m pip install --upgrade --no-cache-dir zerochat", flush=True)
441
+ notice += f" Actualiza cuando quieras con: {sys.executable} -m pip install --upgrade --no-cache-dir zerochat"
387
442
  else:
388
- console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Actualiza con: curl -sSL {REMOTE_SCRIPT_URL} -o zerochat.py", flush=True)
443
+ notice += f" Actualiza con: curl -sSL {REMOTE_SCRIPT_URL} -o zerochat.py"
444
+ add_notice(notice)
389
445
  except Exception:
390
446
  # Modo offline o timeout ignorado de forma segura
391
447
  pass
392
-
393
-
394
448
  # ==============================================================================
395
449
  # Herramientas Locales Core
396
450
  # ==============================================================================
@@ -1163,6 +1217,38 @@ try {
1163
1217
  _BROWSER_SESSION = PersistentBrowserSession()
1164
1218
 
1165
1219
 
1220
+ def browser_action_availability() -> dict:
1221
+ """Comprueba requisitos locales sin lanzar Node.js ni Chromium."""
1222
+ if not shutil.which("node"):
1223
+ return {"available": False, "error": "Node.js is not installed or not found in system PATH."}
1224
+ playwright_locations = (
1225
+ Path.cwd() / "node_modules" / "playwright",
1226
+ get_data_dir() / "services" / "playwright" / "node_modules" / "playwright"
1227
+ )
1228
+ if not any(location.is_dir() for location in playwright_locations):
1229
+ return {"available": False, "error": "Playwright is not available."}
1230
+ browser_commands = (
1231
+ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
1232
+ "msedge", "msedge.exe", "chrome", "chrome.exe"
1233
+ )
1234
+ if sys.platform == "darwin":
1235
+ playwright_cache = Path.home() / "Library" / "Caches" / "ms-playwright"
1236
+ elif sys.platform.startswith("win"):
1237
+ playwright_cache = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "ms-playwright"
1238
+ else:
1239
+ playwright_cache = Path.home() / ".cache" / "ms-playwright"
1240
+ try:
1241
+ has_playwright_browser = playwright_cache.is_dir() and any(
1242
+ child.is_dir() and child.name.startswith("chromium")
1243
+ for child in playwright_cache.iterdir()
1244
+ )
1245
+ except OSError:
1246
+ has_playwright_browser = False
1247
+ if has_playwright_browser or any(shutil.which(command) for command in browser_commands):
1248
+ return {"available": True}
1249
+ return {"available": False, "error": "Playwright Chromium is not installed."}
1250
+
1251
+
1166
1252
  def browser_action(action: str, url: str | None = None, selector: str | None = None, value: str | None = None) -> str:
1167
1253
  """Control a headless browser for UI testing and visual inspection."""
1168
1254
  try:
@@ -1321,7 +1407,6 @@ LOCAL_TOOL_HANDLERS = {
1321
1407
  "get_diagnostics": get_diagnostics,
1322
1408
  "browser_action": browser_action
1323
1409
  }
1324
-
1325
1410
  # ==============================================================================
1326
1411
  # Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
1327
1412
  # ==============================================================================
@@ -2202,7 +2287,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2202
2287
  return
2203
2288
  self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
2204
2289
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
2205
- self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client")
2290
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client, X-ZeroChat-Tool-Session, X-ZeroChat-Tool-Expires, X-ZeroChat-Tool-Nonce, X-ZeroChat-Tool-Signature")
2206
2291
  self.send_header("Access-Control-Allow-Private-Network", "true")
2207
2292
 
2208
2293
  def verify_token(self) -> bool:
@@ -2221,6 +2306,42 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2221
2306
  mark_browser_active()
2222
2307
  return is_valid
2223
2308
 
2309
+ def verify_tool_authorization(self, body: bytes, request_path: str) -> str | None:
2310
+ """Validate and atomically consume an authorization for one tool call."""
2311
+ session_id = self.headers.get("X-ZeroChat-Tool-Session", "")
2312
+ expires_raw = self.headers.get("X-ZeroChat-Tool-Expires", "")
2313
+ nonce = self.headers.get("X-ZeroChat-Tool-Nonce", "")
2314
+ signature = self.headers.get("X-ZeroChat-Tool-Signature", "")
2315
+ if (not isinstance(session_id, str) or len(session_id) > 128 or
2316
+ not isinstance(nonce, str) or not re.fullmatch(r"[A-Za-z0-9_-]{22,128}", nonce) or
2317
+ not isinstance(signature, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43,128}", signature)):
2318
+ return "Missing or invalid tool authorization"
2319
+ try:
2320
+ expires_at = int(expires_raw)
2321
+ except (TypeError, ValueError):
2322
+ return "Missing or invalid tool authorization"
2323
+ now_ms = int(time.time() * 1000)
2324
+ if session_id != TOOL_AUTH_SESSION_ID or expires_at < now_ms or expires_at > now_ms + TOOL_AUTH_TTL_MS:
2325
+ return "Expired or invalid tool authorization"
2326
+ body_hash = hashlib.sha256(body).hexdigest()
2327
+ signature_base = json.dumps(
2328
+ [TOOL_AUTH_VERSION, session_id, "POST", request_path or "/", expires_at, nonce, body_hash],
2329
+ separators=(",", ":"), ensure_ascii=True
2330
+ ).encode("utf-8")
2331
+ expected = base64.urlsafe_b64encode(hmac.new(TOOL_AUTH_KEY, signature_base, hashlib.sha256).digest()).rstrip(b"=").decode("ascii")
2332
+ if not hmac.compare_digest(signature, expected):
2333
+ return "Invalid tool authorization signature"
2334
+ with TOOL_AUTH_NONCES_LOCK:
2335
+ for value, expiry in list(TOOL_AUTH_NONCES.items()):
2336
+ if expiry < now_ms:
2337
+ del TOOL_AUTH_NONCES[value]
2338
+ if nonce in TOOL_AUTH_NONCES:
2339
+ return "Tool authorization was already used"
2340
+ if len(TOOL_AUTH_NONCES) >= TOOL_AUTH_MAX_NONCES:
2341
+ return "Tool authorization cache is full"
2342
+ TOOL_AUTH_NONCES[nonce] = expires_at
2343
+ return None
2344
+
2224
2345
  def do_OPTIONS(self):
2225
2346
  t0 = time.monotonic()
2226
2347
  safe_path = sanitize_log_path(self.path)
@@ -2270,6 +2391,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2270
2391
  self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
2271
2392
  return
2272
2393
 
2394
+ # Browsers request this optional public resource without API credentials.
2395
+ if path_clean == "/favicon.ico":
2396
+ self.send_response(204)
2397
+ self.send_header("Content-Length", "0")
2398
+ self.end_headers()
2399
+ return
2400
+
2273
2401
  if not is_heartbeat:
2274
2402
  self._log_req("GET", safe_path)
2275
2403
 
@@ -2303,12 +2431,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2303
2431
  self._log_res(200, f"{safe_path} [SSE canal activo]", (time.monotonic() - t0) * 1000)
2304
2432
  return
2305
2433
 
2306
- # Status general
2307
2434
  res_data = json.dumps({
2308
2435
  "status": "active",
2309
2436
  "server": "ZeroChat Local Server",
2310
2437
  "version": VERSION,
2438
+ "cwd": str(Path.cwd().resolve()),
2311
2439
  "tools_count": len(LOCAL_TOOLS_DEFINITIONS),
2440
+ "browser_action": browser_action_availability(),
2312
2441
  "os": DETECTED_OS
2313
2442
  }, ensure_ascii=False, indent=2).encode("utf-8")
2314
2443
 
@@ -2431,6 +2560,11 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2431
2560
  self._send_json_response(400, {"error": tool_args_error})
2432
2561
  self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, tool_args_error)
2433
2562
  return
2563
+ auth_error = self.verify_tool_authorization(post_data, self.path.split("?", 1)[0].rstrip("/") or "/")
2564
+ if auth_error:
2565
+ self._send_json_response(403, {"error": auth_error})
2566
+ self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, auth_error)
2567
+ return
2434
2568
 
2435
2569
  req_path = self.path.split("?", 1)[0].rstrip("/")
2436
2570
 
@@ -2469,6 +2603,12 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2469
2603
  },
2470
2604
  "capabilities": {
2471
2605
  "tools": {"listChanged": True}
2606
+ },
2607
+ "toolAuthorization": {
2608
+ "version": TOOL_AUTH_VERSION,
2609
+ "sessionId": TOOL_AUTH_SESSION_ID,
2610
+ "key": base64.urlsafe_b64encode(TOOL_AUTH_KEY).rstrip(b"=").decode("ascii"),
2611
+ "ttlMs": TOOL_AUTH_TTL_MS
2472
2612
  }
2473
2613
  }
2474
2614
  elif method == "tools/list":
@@ -2498,14 +2638,34 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2498
2638
  "protocolVersion": "2024-11-05",
2499
2639
  "serverInfo": {
2500
2640
  "name": "ZeroChat Local Server",
2501
- "version": VERSION
2641
+ "version": VERSION,
2642
+ "cwd": str(Path.cwd().resolve())
2502
2643
  },
2503
2644
  "capabilities": {
2504
2645
  "tools": {"listChanged": True}
2646
+ },
2647
+ "toolAuthorization": {
2648
+ "version": TOOL_AUTH_VERSION,
2649
+ "sessionId": TOOL_AUTH_SESSION_ID,
2650
+ "key": base64.urlsafe_b64encode(TOOL_AUTH_KEY).rstrip(b"=").decode("ascii"),
2651
+ "ttlMs": TOOL_AUTH_TTL_MS
2505
2652
  }
2506
2653
  }
2507
2654
  elif method == "tools/list":
2508
- result = {"tools": list(LOCAL_TOOLS_DEFINITIONS)}
2655
+ browser_availability = browser_action_availability()
2656
+ tools = []
2657
+ for definition in LOCAL_TOOLS_DEFINITIONS:
2658
+ item = dict(definition)
2659
+ if item.get("name") == "browser_action":
2660
+ item["availability"] = browser_availability
2661
+ tools.append(item)
2662
+ result = {"tools": tools}
2663
+ elif method == "tools/availability":
2664
+ requested_name = params.get("name", "") if isinstance(params, dict) else ""
2665
+ if requested_name != "browser_action":
2666
+ error = {"code": -32602, "message": "Herramienta no compatible con comprobación de disponibilidad."}
2667
+ else:
2668
+ result = browser_action_availability()
2509
2669
  elif method == "tools/call":
2510
2670
  tool_name = params.get("name", "") if isinstance(params, dict) else ""
2511
2671
  tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}
@@ -2595,7 +2755,6 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
2595
2755
  def log_message(self, format, *args):
2596
2756
  # Silenciar logs ruidosos por defecto
2597
2757
  pass
2598
-
2599
2758
  def is_termux_environment() -> bool:
2600
2759
  """Devuelve si el proceso se ejecuta dentro de la instalación de Termux."""
2601
2760
  termux_prefix = "/data/data/com.termux/files/usr"
@@ -2752,6 +2911,7 @@ def launch_browser(url: str) -> bool:
2752
2911
 
2753
2912
  def main():
2754
2913
  global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL
2914
+ global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
2755
2915
 
2756
2916
  parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
2757
2917
  parser.add_argument("--port", type=int, default=int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT)), help=f"Puerto de escucha (default: {DEFAULT_PORT})")
@@ -2765,6 +2925,9 @@ def main():
2765
2925
  parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
2766
2926
  args = parser.parse_args()
2767
2927
 
2928
+ # Los avisos no persisten entre ejecuciones del servidor.
2929
+ reset_notices()
2930
+
2768
2931
  if args.test:
2769
2932
  print(f"[{time.strftime('%H:%M:%S')}] TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}")
2770
2933
  print(f"[{time.strftime('%H:%M:%S')}] TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}")
@@ -2776,6 +2939,14 @@ def main():
2776
2939
  if not args.no_venv:
2777
2940
  ensure_virtual_environment()
2778
2941
 
2942
+ browser_availability = browser_action_availability()
2943
+ if not browser_availability.get("available"):
2944
+ add_notice(
2945
+ "browser_action no está disponible y permanecerá desactivada. "
2946
+ "Instala Node.js, Playwright y Chromium: "
2947
+ "https://albalday.github.io/zerochat/help/browser-action.html"
2948
+ )
2949
+
2779
2950
  # 2. Detectar entorno de desarrollo y resolver URL de destino
2780
2951
  dev_root = get_dev_root()
2781
2952
  static_root = get_static_root()
@@ -2792,6 +2963,11 @@ def main():
2792
2963
  SESSION_TOKEN = args.token
2793
2964
  else:
2794
2965
  SESSION_TOKEN = get_daily_token()
2966
+ # A restart deliberately invalidates every browser-side signing credential.
2967
+ TOOL_AUTH_KEY = secrets.token_bytes(32)
2968
+ TOOL_AUTH_SESSION_ID = secrets.token_urlsafe(18)
2969
+ with TOOL_AUTH_NONCES_LOCK:
2970
+ TOOL_AUTH_NONCES = {}
2795
2971
 
2796
2972
  server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
2797
2973
 
File without changes
File without changes
File without changes