zerochat 7.7.0__tar.gz → 7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 7.7.0
3
+ Version: 7.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 = "7.7.0"
7
+ version = "7.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: 7.7.0
3
+ Version: 7.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
@@ -12,8 +12,10 @@ Proporciona:
12
12
  from __future__ import annotations
13
13
 
14
14
  import argparse
15
+ import ast
15
16
  import atexit
16
17
  import datetime
18
+ import fnmatch
17
19
  import hmac
18
20
  import importlib.metadata
19
21
  import json
@@ -27,6 +29,7 @@ import shlex
27
29
  import signal
28
30
  import subprocess
29
31
  import sys
32
+ import tempfile
30
33
  import threading
31
34
  import time
32
35
  import traceback
@@ -37,7 +40,7 @@ import webbrowser
37
40
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
41
  from pathlib import Path
39
42
 
40
- SOURCE_BACKEND_VERSION = "7.7.0"
43
+ SOURCE_BACKEND_VERSION = "7.10.0"
41
44
 
42
45
  def _read_source_version(filename: str) -> str | None:
43
46
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -388,34 +391,54 @@ def check_version():
388
391
  # Herramientas Locales Core
389
392
  # ==============================================================================
390
393
 
391
- def list_directory(path: str = ".", max_depth: int = 1) -> str:
392
- """Recorre un directorio local y devuelve la lista de archivos y carpetas."""
394
+ def list_directory(path: str = ".", recursive: bool = False) -> str:
395
+ """List files and directories in a local directory with safe bounded recursive traversal."""
393
396
  try:
394
397
  target = Path(path).expanduser().resolve()
395
398
  if not target.exists():
396
- return json.dumps({"success": False, "error": f"La ruta '{path}' no existe."}, ensure_ascii=False)
399
+ return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
397
400
  if not target.is_dir():
398
- return json.dumps({"success": False, "error": f"La ruta '{path}' no es un directorio."}, ensure_ascii=False)
401
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)
399
402
 
403
+ max_depth = 3 if recursive else 1
400
404
  entries = []
401
- for entry in os.scandir(target):
405
+
406
+ def _scan(current_path: Path, current_depth: int):
407
+ if current_depth > max_depth or len(entries) >= 1000:
408
+ return
402
409
  try:
403
- stat = entry.stat(follow_symlinks=False)
404
- is_dir = entry.is_dir(follow_symlinks=False)
405
- entries.append({
406
- "name": entry.name,
407
- "path": str(Path(entry.path).resolve()),
408
- "type": "directory" if is_dir else "file",
409
- "size_bytes": None if is_dir else stat.st_size,
410
- "is_symlink": entry.is_symlink()
411
- })
410
+ with os.scandir(current_path) as it:
411
+ items = list(it)
412
+ items.sort(key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()))
413
+ for entry in items:
414
+ if len(entries) >= 1000:
415
+ break
416
+ try:
417
+ stat = entry.stat(follow_symlinks=False)
418
+ is_dir = entry.is_dir(follow_symlinks=False)
419
+ entry_data = {
420
+ "name": entry.name,
421
+ "path": str(Path(entry.path).resolve()),
422
+ "type": "directory" if is_dir else "file",
423
+ "size_bytes": None if is_dir else stat.st_size,
424
+ "is_symlink": entry.is_symlink()
425
+ }
426
+ if recursive:
427
+ entry_data["relative_path"] = str(Path(entry.path).resolve().relative_to(target))
428
+ entries.append(entry_data)
429
+ if recursive and is_dir and not entry.is_symlink():
430
+ _scan(Path(entry.path), current_depth + 1)
431
+ except (PermissionError, FileNotFoundError):
432
+ continue
412
433
  except (PermissionError, FileNotFoundError):
413
- continue
434
+ pass
435
+
436
+ _scan(target, 1)
414
437
 
415
- entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
416
438
  return json.dumps({
417
439
  "success": True,
418
440
  "path": str(target),
441
+ "recursive": bool(recursive),
419
442
  "total_items": len(entries),
420
443
  "entries": entries
421
444
  }, ensure_ascii=False, indent=2)
@@ -423,34 +446,65 @@ def list_directory(path: str = ".", max_depth: int = 1) -> str:
423
446
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
424
447
 
425
448
 
426
- def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: int = 100000) -> str:
427
- """Lee el contenido de texto de un archivo local con rangos y límites seguros."""
449
+ def read_file(path: str, start_line: int = 1, end_line: int = None, max_lines: int = 500, max_bytes: int = 100000) -> str:
450
+ """Read text content from a local file in streaming mode with safe ranges and bounds."""
428
451
  try:
429
452
  target = Path(path).expanduser().resolve()
430
453
  if not target.exists():
431
- return json.dumps({"success": False, "error": f"El archivo '{path}' no existe."}, ensure_ascii=False)
454
+ return json.dumps({"success": False, "error": f"File '{path}' does not exist."}, ensure_ascii=False)
432
455
  if not target.is_file():
433
- return json.dumps({"success": False, "error": f"La ruta '{path}' no es un archivo regular."}, ensure_ascii=False)
456
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
434
457
 
435
458
  file_size = target.stat().st_size
436
459
  safe_max_bytes = max(1024, min(int(max_bytes), 2000000))
437
460
  safe_start_line = max(1, int(start_line))
438
- safe_max_lines = max(1, min(int(max_lines), 2000))
461
+
462
+ if end_line is not None:
463
+ safe_end_line = max(safe_start_line, int(end_line))
464
+ target_line_count = safe_end_line - safe_start_line + 1
465
+ else:
466
+ safe_end_line = None
467
+ target_line_count = max(1, min(int(max_lines), 2000))
468
+
469
+ selected_lines = []
470
+ current_bytes = 0
471
+ truncated_bytes = False
472
+ total_lines = 0
473
+ reached_end_bound = False
439
474
 
440
475
  with open(target, "r", encoding="utf-8", errors="replace") as f:
441
- lines = f.readlines()
476
+ for line_no, line in enumerate(f, 1):
477
+ total_lines = line_no
478
+ if line_no < safe_start_line:
479
+ continue
480
+ if safe_end_line is not None and line_no > safe_end_line:
481
+ reached_end_bound = True
482
+ if line_no > safe_start_line + 50000:
483
+ break
484
+ continue
485
+ if safe_end_line is None and len(selected_lines) >= target_line_count:
486
+ reached_end_bound = True
487
+ if line_no > safe_start_line + 50000:
488
+ break
489
+ continue
442
490
 
443
- total_lines = len(lines)
444
- start_idx = safe_start_line - 1
445
- end_idx = min(start_idx + safe_max_lines, total_lines)
491
+ if not reached_end_bound:
492
+ line_bytes = len(line.encode("utf-8"))
493
+ if current_bytes + line_bytes > safe_max_bytes:
494
+ remaining_budget = max(0, safe_max_bytes - current_bytes)
495
+ if remaining_budget > 0:
496
+ encoded = line.encode("utf-8")[:remaining_budget]
497
+ selected_lines.append(encoded.decode("utf-8", errors="ignore"))
498
+ truncated_bytes = True
499
+ reached_end_bound = True
500
+ else:
501
+ selected_lines.append(line)
502
+ current_bytes += line_bytes
446
503
 
447
- selected_lines = lines[start_idx:end_idx] if start_idx < total_lines else []
448
504
  content = "".join(selected_lines)
505
+ last_returned_line = safe_start_line + len(selected_lines) - 1 if selected_lines else safe_start_line - 1
449
506
 
450
- truncated_bytes = False
451
- if len(content.encode("utf-8")) > safe_max_bytes:
452
- content = content[:safe_max_bytes]
453
- truncated_bytes = True
507
+ is_truncated = truncated_bytes or (safe_end_line is not None and safe_end_line < total_lines) or (safe_end_line is None and (safe_start_line + target_line_count - 1) < total_lines)
454
508
 
455
509
  return json.dumps({
456
510
  "success": True,
@@ -458,44 +512,103 @@ def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: i
458
512
  "size_bytes": file_size,
459
513
  "total_lines": total_lines,
460
514
  "start_line": safe_start_line,
515
+ "end_line": last_returned_line,
461
516
  "lines_returned": len(selected_lines),
462
- "truncated": truncated_bytes or end_idx < total_lines,
517
+ "truncated": is_truncated,
463
518
  "content": content
464
519
  }, ensure_ascii=False, indent=2)
465
520
  except Exception as e:
466
521
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
467
522
 
468
523
 
469
- def edit_file(path: str, content: str, mode: str = "write", target_content: str = None) -> str:
470
- """Crea, sobrescribe o edita un archivo de forma atómica."""
524
+ def write_file(path: str, content: str) -> str:
525
+ """Create or overwrite a file completely and atomically."""
471
526
  try:
472
527
  target = Path(path).expanduser().resolve()
473
528
  target.parent.mkdir(parents=True, exist_ok=True)
529
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
530
+ with open(temp_path, "w", encoding="utf-8") as f:
531
+ f.write(content)
532
+ temp_path.replace(target)
533
+ bytes_written = len(content.encode("utf-8"))
534
+ return json.dumps({
535
+ "success": True,
536
+ "path": str(target),
537
+ "bytes_written": bytes_written
538
+ }, ensure_ascii=False, indent=2)
539
+ except Exception as e:
540
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
474
541
 
542
+
543
+ def edit_file(path: str, old_str: str = None, new_str: str = None, content: str = None, mode: str = "surgical", target_content: str = None) -> str:
544
+ """Modify an existing file surgically or atomically."""
545
+ try:
546
+ target = Path(path).expanduser().resolve()
547
+
548
+ # Preferred surgical mode (old_str -> new_str)
549
+ if old_str is not None and new_str is not None:
550
+ if not target.exists():
551
+ return json.dumps({"success": False, "error": f"File '{path}' does not exist. Use read_file to verify existing paths."}, ensure_ascii=False)
552
+ if not target.is_file():
553
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
554
+
555
+ with open(target, "r", encoding="utf-8", errors="replace") as f:
556
+ file_text = f.read()
557
+
558
+ occurrences = file_text.count(old_str)
559
+ if occurrences == 0:
560
+ return json.dumps({
561
+ "success": False,
562
+ "error": f"Target text was not found in '{path}'. Check exact file content with read_file."
563
+ }, ensure_ascii=False)
564
+ if occurrences > 1:
565
+ return json.dumps({
566
+ "success": False,
567
+ "error": f"Found {occurrences} matches for the snippet in '{path}'. Provide more surrounding context in old_str to ensure a unique match."
568
+ }, ensure_ascii=False)
569
+
570
+ updated_text = file_text.replace(old_str, new_str, 1)
571
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
572
+ with open(temp_path, "w", encoding="utf-8") as f:
573
+ f.write(updated_text)
574
+ temp_path.replace(target)
575
+ return json.dumps({
576
+ "success": True,
577
+ "path": str(target),
578
+ "mode": "surgical",
579
+ "bytes_written": len(updated_text.encode("utf-8")),
580
+ "replacements": 1
581
+ }, ensure_ascii=False, indent=2)
582
+
583
+ # Legacy modes (write, append, replace_chunk)
584
+ if content is None:
585
+ return json.dumps({"success": False, "error": "Must provide old_str and new_str for surgical edit, or content for compatible modes."}, ensure_ascii=False)
586
+
587
+ target.parent.mkdir(parents=True, exist_ok=True)
475
588
  if mode == "append":
476
589
  with open(target, "a", encoding="utf-8") as f:
477
590
  f.write(content)
478
591
  bytes_written = len(content.encode("utf-8"))
479
592
  elif mode == "replace_chunk":
480
593
  if not target.exists():
481
- return json.dumps({"success": False, "error": f"El archivo '{path}' no existe para replace_chunk."}, ensure_ascii=False)
594
+ return json.dumps({"success": False, "error": f"File '{path}' does not exist for replace_chunk."}, ensure_ascii=False)
482
595
  if not target_content:
483
- return json.dumps({"success": False, "error": "target_content es obligatorio en modo replace_chunk."}, ensure_ascii=False)
596
+ return json.dumps({"success": False, "error": "target_content is required in replace_chunk mode."}, ensure_ascii=False)
484
597
 
485
598
  with open(target, "r", encoding="utf-8", errors="replace") as f:
486
599
  existing = f.read()
487
600
 
488
601
  if target_content not in existing:
489
- return json.dumps({"success": False, "error": "target_content no fue encontrado en el archivo."}, ensure_ascii=False)
602
+ return json.dumps({"success": False, "error": "target_content was not found in the file."}, ensure_ascii=False)
490
603
 
491
604
  new_text = existing.replace(target_content, content, 1)
492
- temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
605
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
493
606
  with open(temp_path, "w", encoding="utf-8") as f:
494
607
  f.write(new_text)
495
608
  temp_path.replace(target)
496
609
  bytes_written = len(new_text.encode("utf-8"))
497
610
  else: # write
498
- temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
611
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
499
612
  with open(temp_path, "w", encoding="utf-8") as f:
500
613
  f.write(content)
501
614
  temp_path.replace(target)
@@ -511,8 +624,135 @@ def edit_file(path: str, content: str, mode: str = "write", target_content: str
511
624
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
512
625
 
513
626
 
627
+ def truncate_terminal_output(text: str, max_chars: int = 8000, head_lines: int = 50, tail_lines: int = 30) -> tuple[str, bool]:
628
+ """Outputs >8,000 characters are truncated to first 50 lines + warning + last 30 lines."""
629
+ if len(text) <= max_chars:
630
+ return text, False
631
+ lines = text.splitlines(keepends=True)
632
+ if len(lines) <= head_lines + tail_lines:
633
+ half = max_chars // 2
634
+ return (
635
+ text[:half]
636
+ + f"\n\n[... Output truncated due to length ({len(text)} total characters) ...]\n\n"
637
+ + text[-half:],
638
+ True
639
+ )
640
+ omitted = len(lines) - head_lines - tail_lines
641
+ head = "".join(lines[:head_lines])
642
+ tail = "".join(lines[-tail_lines:])
643
+ warning = f"\n\n[... Output truncated: omitted {omitted} lines ({len(text)} total characters) ...]\n\n"
644
+ return head + warning + tail, True
645
+
646
+
647
+ class PersistentBashSession:
648
+ """Maintains working directory (cwd) and environment variables across successive calls."""
649
+ def __init__(self):
650
+ self._dir = tempfile.mkdtemp(prefix="zerochat_bash_")
651
+ self._cwd_file = Path(self._dir) / "cwd"
652
+ self._env_file = Path(self._dir) / "env.sh"
653
+ self._cwd = str(Path.cwd().resolve())
654
+ self._lock = threading.Lock()
655
+ atexit.register(self.cleanup)
656
+
657
+ def cleanup(self):
658
+ try:
659
+ shutil.rmtree(self._dir, ignore_errors=True)
660
+ except Exception:
661
+ pass
662
+
663
+ def run(self, command: str, timeout_seconds: int = 30) -> str:
664
+ with self._lock:
665
+ safe_timeout = max(1, min(int(timeout_seconds), 300))
666
+ runner_script = Path(self._dir) / f"runner_{time.time_ns()}.sh"
667
+
668
+ script_content = (
669
+ "if [ -f " + shlex.quote(str(self._env_file)) + " ]; then\n"
670
+ " . " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true\n"
671
+ "fi\n"
672
+ "cd " + shlex.quote(self._cwd) + " 2>/dev/null || true\n"
673
+ "trap '__ret=$?; pwd > " + shlex.quote(str(self._cwd_file)) + "; export -p > " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true; exit $__ret' EXIT\n"
674
+ + command + "\n"
675
+ )
676
+
677
+ try:
678
+ with open(runner_script, "w", encoding="utf-8") as f:
679
+ f.write(script_content)
680
+ runner_script.chmod(0o700)
681
+
682
+ proc = subprocess.Popen(
683
+ ["bash", str(runner_script)],
684
+ cwd=self._cwd,
685
+ stdout=subprocess.PIPE,
686
+ stderr=subprocess.PIPE,
687
+ text=True,
688
+ encoding="utf-8",
689
+ errors="replace",
690
+ start_new_session=True if hasattr(os, "setsid") else False
691
+ )
692
+
693
+ try:
694
+ stdout, stderr = proc.communicate(timeout=safe_timeout)
695
+ returncode = proc.returncode
696
+ except subprocess.TimeoutExpired:
697
+ try:
698
+ if hasattr(os, "killpg") and hasattr(os, "getpgid"):
699
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
700
+ else:
701
+ proc.kill()
702
+ except Exception:
703
+ try:
704
+ proc.kill()
705
+ except Exception:
706
+ pass
707
+ proc.communicate()
708
+ return json.dumps({
709
+ "success": False,
710
+ "error": f"Command timed out after {timeout_seconds} seconds (terminated with SIGKILL).",
711
+ "cwd": self._cwd
712
+ }, ensure_ascii=False)
713
+
714
+ if self._cwd_file.is_file():
715
+ try:
716
+ saved_cwd = self._cwd_file.read_text(encoding="utf-8").strip()
717
+ if saved_cwd and Path(saved_cwd).is_dir():
718
+ self._cwd = saved_cwd
719
+ except Exception:
720
+ pass
721
+
722
+ truncated_out, was_out_trunc = truncate_terminal_output(stdout)
723
+ truncated_err, was_err_trunc = truncate_terminal_output(stderr)
724
+
725
+ return json.dumps({
726
+ "success": returncode == 0,
727
+ "returncode": returncode,
728
+ "stdout": truncated_out,
729
+ "stderr": truncated_err,
730
+ "cwd": self._cwd,
731
+ "truncated": was_out_trunc or was_err_trunc
732
+ }, ensure_ascii=False, indent=2)
733
+
734
+ except Exception as e:
735
+ return json.dumps({"success": False, "error": str(e), "cwd": self._cwd}, ensure_ascii=False)
736
+ finally:
737
+ try:
738
+ if runner_script.is_file():
739
+ runner_script.unlink()
740
+ except Exception:
741
+ pass
742
+
743
+
744
+ BASH_SESSION = PersistentBashSession()
745
+
746
+
747
+ def bash(command: str, timeout_seconds: int = 30) -> str:
748
+ """Execute a command in an interactive persistent bash shell session."""
749
+ return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
750
+
751
+
514
752
  def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) -> str:
515
- """Ejecuta un comando en la shell del sistema y devuelve stdout y stderr."""
753
+ """Execute a command in the system shell and capture stdout and stderr."""
754
+ if cwd == "." or cwd == BASH_SESSION._cwd:
755
+ return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
516
756
  try:
517
757
  target_cwd = Path(cwd).expanduser().resolve()
518
758
  if not target_cwd.exists() or not target_cwd.is_dir():
@@ -528,15 +768,417 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
528
768
  encoding="utf-8",
529
769
  errors="replace"
530
770
  )
771
+ trunc_out, was_out_trunc = truncate_terminal_output(proc.stdout)
772
+ trunc_err, was_err_trunc = truncate_terminal_output(proc.stderr)
531
773
  return json.dumps({
532
774
  "success": proc.returncode == 0,
533
775
  "returncode": proc.returncode,
534
- "stdout": proc.stdout,
535
- "stderr": proc.stderr,
536
- "cwd": str(target_cwd)
776
+ "stdout": trunc_out,
777
+ "stderr": trunc_err,
778
+ "cwd": str(target_cwd),
779
+ "truncated": was_out_trunc or was_err_trunc
537
780
  }, ensure_ascii=False, indent=2)
538
781
  except subprocess.TimeoutExpired:
539
- return json.dumps({"success": False, "error": f"Comando excedió el tiempo límite de {timeout_seconds} segundos."}, ensure_ascii=False)
782
+ return json.dumps({"success": False, "error": f"Command timed out after {timeout_seconds} seconds."}, ensure_ascii=False)
783
+ except Exception as e:
784
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
785
+
786
+
787
+ def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
788
+ """Recursively search for plain text or regular expressions across project files."""
789
+ try:
790
+ target = Path(path).expanduser().resolve()
791
+ if not target.exists():
792
+ return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
793
+ if not target.is_dir():
794
+ return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)
795
+
796
+ try:
797
+ regex = re.compile(query, re.MULTILINE)
798
+ except re.error:
799
+ regex = re.compile(re.escape(query), re.MULTILINE)
800
+
801
+ ignored_dirs = {".git", ".venv", "node_modules", "__pycache__", ".pytest_cache", ".cache"}
802
+ matches = []
803
+ files_searched = 0
804
+ max_file_size = 2 * 1024 * 1024
805
+ truncated = False
806
+
807
+ for root, dirs, files in os.walk(target):
808
+ dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]
809
+
810
+ for fname in sorted(files):
811
+ if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
812
+ continue
813
+
814
+ fpath = Path(root) / fname
815
+ try:
816
+ stat = fpath.stat()
817
+ if stat.st_size > max_file_size:
818
+ continue
819
+ except OSError:
820
+ continue
821
+
822
+ files_searched += 1
823
+ try:
824
+ with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
825
+ for line_idx, line in enumerate(f, 1):
826
+ if regex.search(line):
827
+ try:
828
+ rel_path = str(fpath.relative_to(target))
829
+ except ValueError:
830
+ rel_path = str(fpath)
831
+ matches.append({
832
+ "file": str(fpath),
833
+ "relative_path": rel_path,
834
+ "line_number": line_idx,
835
+ "content": line.rstrip("\r\n")[:300]
836
+ })
837
+ if len(matches) >= max_results:
838
+ truncated = True
839
+ break
840
+ except (PermissionError, OSError):
841
+ continue
842
+
843
+ if truncated:
844
+ break
845
+ if truncated:
846
+ break
847
+
848
+ return json.dumps({
849
+ "success": True,
850
+ "path": str(target),
851
+ "query": query,
852
+ "file_pattern": file_pattern,
853
+ "files_searched": files_searched,
854
+ "total_matches": len(matches),
855
+ "truncated": truncated,
856
+ "matches": matches
857
+ }, ensure_ascii=False, indent=2)
858
+ except Exception as e:
859
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
860
+
861
+
862
+ def get_diagnostics(path: str | None = None) -> str:
863
+ """Retrieve syntax diagnostics and code errors for a file or the entire workspace."""
864
+ try:
865
+ if path is not None and str(path).strip():
866
+ target = Path(str(path).strip()).expanduser().resolve()
867
+ else:
868
+ target = Path.cwd().resolve()
869
+
870
+ if not target.exists():
871
+ return json.dumps({
872
+ "success": False,
873
+ "error": f"Path '{path}' does not exist."
874
+ }, ensure_ascii=False)
875
+
876
+ diagnostics: list[dict] = []
877
+ files_checked = 0
878
+
879
+ def check_single_file(fpath: Path) -> list[dict]:
880
+ ext = fpath.suffix.lower()
881
+ file_diags: list[dict] = []
882
+ try:
883
+ rel_display = str(fpath.relative_to(Path.cwd()))
884
+ except ValueError:
885
+ rel_display = str(fpath)
886
+
887
+ if ext == ".py":
888
+ try:
889
+ content = fpath.read_text(encoding="utf-8", errors="replace")
890
+ compile(content, str(fpath), "exec")
891
+ except (SyntaxError, IndentationError) as e:
892
+ file_diags.append({
893
+ "file": rel_display,
894
+ "line": e.lineno or 1,
895
+ "column": e.offset or 1,
896
+ "severity": "error",
897
+ "message": f"SyntaxError: {e.msg}"
898
+ })
899
+ elif ext in (".js", ".mjs", ".cjs"):
900
+ node_bin = shutil.which("node")
901
+ if node_bin:
902
+ try:
903
+ res = subprocess.run([node_bin, "--check", str(fpath)], capture_output=True, text=True, timeout=10)
904
+ if res.returncode != 0:
905
+ stderr = res.stderr or ""
906
+ line_match = re.search(r':(\d+)\n([^\n]+)\n(\s*)\^', stderr)
907
+ line = int(line_match.group(1)) if line_match else 1
908
+ col = len(line_match.group(3)) + 1 if line_match else 1
909
+ msg_match = re.search(r'(SyntaxError:[^\n]+)', stderr)
910
+ msg = msg_match.group(1) if msg_match else (stderr.strip().splitlines()[-1] if stderr.strip() else "Syntax error")
911
+ file_diags.append({
912
+ "file": rel_display,
913
+ "line": line,
914
+ "column": col,
915
+ "severity": "error",
916
+ "message": msg
917
+ })
918
+ except Exception:
919
+ pass
920
+ elif ext == ".json":
921
+ try:
922
+ content = fpath.read_text(encoding="utf-8", errors="replace")
923
+ json.loads(content)
924
+ except json.JSONDecodeError as e:
925
+ file_diags.append({
926
+ "file": rel_display,
927
+ "line": e.lineno,
928
+ "column": e.colno,
929
+ "severity": "error",
930
+ "message": f"JSONDecodeError: {e.msg}"
931
+ })
932
+ return file_diags
933
+
934
+ if target.is_file():
935
+ files_checked = 1
936
+ diagnostics.extend(check_single_file(target))
937
+ else:
938
+ ignore_dirs = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".gemini", ".cache"}
939
+ max_scan = 200
940
+ for root, dirs, files in os.walk(target):
941
+ dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
942
+ for fname in sorted(files):
943
+ fp = Path(root) / fname
944
+ if fp.suffix.lower() in (".py", ".js", ".mjs", ".cjs", ".json"):
945
+ files_checked += 1
946
+ diagnostics.extend(check_single_file(fp))
947
+ if files_checked >= max_scan:
948
+ break
949
+ if files_checked >= max_scan:
950
+ break
951
+
952
+ error_count = sum(1 for d in diagnostics if d.get("severity") == "error")
953
+ warning_count = sum(1 for d in diagnostics if d.get("severity") == "warning")
954
+ msg = f"Found {len(diagnostics)} issue(s)." if diagnostics else "No diagnostic issues found."
955
+
956
+ return json.dumps({
957
+ "success": True,
958
+ "path": str(target),
959
+ "files_checked": files_checked,
960
+ "error_count": error_count,
961
+ "warning_count": warning_count,
962
+ "diagnostics": diagnostics,
963
+ "message": msg
964
+ }, ensure_ascii=False, indent=2)
965
+ except Exception as e:
966
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
967
+
968
+
969
+ class PersistentBrowserSession:
970
+ """Manages a persistent headless browser session (Playwright) for browser_action."""
971
+
972
+ def __init__(self):
973
+ self._lock = threading.Lock()
974
+ self._process: subprocess.Popen | None = None
975
+ atexit.register(self.close)
976
+
977
+ def _ensure_running(self) -> subprocess.Popen:
978
+ if self._process is not None and self._process.poll() is None:
979
+ return self._process
980
+
981
+ node = shutil.which("node")
982
+ if not node:
983
+ raise RuntimeError("Node.js is not installed or not found in system PATH.")
984
+
985
+ runner_js = """
986
+ const readline = require('readline');
987
+ let playwright;
988
+ try {
989
+ playwright = require('playwright');
990
+ } catch (e1) {
991
+ try {
992
+ const path = require('path');
993
+ const home = process.env.HOME || process.env.USERPROFILE || '';
994
+ playwright = require(path.join(home, 'zerochat', 'services', 'playwright', 'node_modules', 'playwright'));
995
+ } catch (e2) {
996
+ console.log(JSON.stringify({
997
+ success: false,
998
+ error: "Playwright is not available. Install it with 'npm install playwright' or enable the Playwright MCP service."
999
+ }));
1000
+ process.exit(1);
1001
+ }
1002
+ }
1003
+
1004
+ (async () => {
1005
+ let browser, context, page;
1006
+ try {
1007
+ const candidates = [
1008
+ {}, // 1. Playwright bundled Chromium
1009
+ { channel: 'chrome' }, // 2. System Google Chrome
1010
+ { channel: 'msedge' }, // 3. System Microsoft Edge (Windows 10/11)
1011
+ { channel: 'chromium' } // 4. System Chromium (/usr/bin/chromium)
1012
+ ];
1013
+ let lastErr = null;
1014
+ for (const cand of candidates) {
1015
+ try {
1016
+ browser = await playwright.chromium.launch({ ...cand, headless: true });
1017
+ if (browser) break;
1018
+ } catch (e) {
1019
+ lastErr = e;
1020
+ }
1021
+ }
1022
+ if (!browser) {
1023
+ throw lastErr || new Error('No Chromium-based browser found (Playwright, Chrome, Edge, or Chromium).');
1024
+ }
1025
+ context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
1026
+ page = await context.newPage();
1027
+ } catch (err) {
1028
+ console.log(JSON.stringify({ success: false, error: 'Failed to start browser: ' + err.message }));
1029
+ process.exit(1);
1030
+ }
1031
+
1032
+ console.log(JSON.stringify({ ready: true }));
1033
+
1034
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
1035
+ for await (const line of rl) {
1036
+ if (!line.trim()) continue;
1037
+ let req;
1038
+ try {
1039
+ req = JSON.parse(line);
1040
+ } catch (e) {
1041
+ console.log(JSON.stringify({ success: false, error: 'Invalid command JSON' }));
1042
+ continue;
1043
+ }
1044
+
1045
+ try {
1046
+ const act = req.action;
1047
+ if (act === 'navigate') {
1048
+ if (!req.url) throw new Error("Parameter 'url' is required for action 'navigate'");
1049
+ await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1050
+ console.log(JSON.stringify({
1051
+ success: true,
1052
+ action: 'navigate',
1053
+ url: page.url(),
1054
+ title: await page.title()
1055
+ }));
1056
+ } else if (act === 'screenshot') {
1057
+ if (req.url && req.url !== page.url()) {
1058
+ await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1059
+ }
1060
+ const buf = await page.screenshot({ fullPage: false });
1061
+ console.log(JSON.stringify({
1062
+ success: true,
1063
+ action: 'screenshot',
1064
+ image_base64: buf.toString('base64'),
1065
+ mime_type: 'image/png',
1066
+ url: page.url(),
1067
+ title: await page.title()
1068
+ }));
1069
+ } else if (act === 'click') {
1070
+ if (!req.selector) throw new Error("Parameter 'selector' is required for action 'click'");
1071
+ await page.click(req.selector, { timeout: 15000 });
1072
+ console.log(JSON.stringify({
1073
+ success: true,
1074
+ action: 'click',
1075
+ selector: req.selector,
1076
+ url: page.url()
1077
+ }));
1078
+ } else if (act === 'fill') {
1079
+ if (!req.selector) throw new Error("Parameter 'selector' is required for action 'fill'");
1080
+ await page.fill(req.selector, req.value || '', { timeout: 15000 });
1081
+ console.log(JSON.stringify({
1082
+ success: true,
1083
+ action: 'fill',
1084
+ selector: req.selector,
1085
+ value: req.value || '',
1086
+ url: page.url()
1087
+ }));
1088
+ } else if (act === 'close') {
1089
+ await browser.close();
1090
+ console.log(JSON.stringify({ success: true, action: 'close' }));
1091
+ process.exit(0);
1092
+ } else {
1093
+ console.log(JSON.stringify({ success: false, error: 'Unsupported action: ' + act }));
1094
+ }
1095
+ } catch (err) {
1096
+ console.log(JSON.stringify({ success: false, error: err.message, action: req.action }));
1097
+ }
1098
+ }
1099
+ })();
1100
+ """
1101
+ proc = subprocess.Popen(
1102
+ [node, "-e", runner_js],
1103
+ stdin=subprocess.PIPE,
1104
+ stdout=subprocess.PIPE,
1105
+ stderr=subprocess.PIPE,
1106
+ text=True,
1107
+ encoding="utf-8",
1108
+ bufsize=1
1109
+ )
1110
+ self._process = proc
1111
+ first_line = proc.stdout.readline()
1112
+ if not first_line:
1113
+ err = proc.stderr.read()
1114
+ self._process = None
1115
+ raise RuntimeError(f"Failed to initialize headless browser: {err or 'process terminated unexpectedly'}")
1116
+ data = json.loads(first_line)
1117
+ if not data.get("ready"):
1118
+ self._process = None
1119
+ raise RuntimeError(data.get("error", "Unknown error starting browser"))
1120
+
1121
+ return proc
1122
+
1123
+ def execute(self, command: dict) -> dict:
1124
+ with self._lock:
1125
+ try:
1126
+ proc = self._ensure_running()
1127
+ except Exception as e:
1128
+ return {"success": False, "error": str(e)}
1129
+
1130
+ req_json = json.dumps(command, ensure_ascii=False) + "\n"
1131
+ try:
1132
+ proc.stdin.write(req_json)
1133
+ proc.stdin.flush()
1134
+ resp_line = proc.stdout.readline()
1135
+ if not resp_line:
1136
+ self.close()
1137
+ return {"success": False, "error": "Browser process closed unexpectedly."}
1138
+ return json.loads(resp_line)
1139
+ except Exception as e:
1140
+ self.close()
1141
+ return {"success": False, "error": f"Error executing browser action: {e}"}
1142
+
1143
+ def close(self):
1144
+ with self._lock:
1145
+ if self._process is not None:
1146
+ try:
1147
+ if self._process.poll() is None:
1148
+ try:
1149
+ self._process.stdin.write(json.dumps({"action": "close"}) + "\n")
1150
+ self._process.stdin.flush()
1151
+ self._process.wait(timeout=2)
1152
+ except Exception:
1153
+ self._process.kill()
1154
+ except Exception:
1155
+ pass
1156
+ self._process = None
1157
+
1158
+
1159
+ _BROWSER_SESSION = PersistentBrowserSession()
1160
+
1161
+
1162
+ def browser_action(action: str, url: str | None = None, selector: str | None = None, value: str | None = None) -> str:
1163
+ """Control a headless browser for UI testing and visual inspection."""
1164
+ try:
1165
+ act = (action or "").strip().lower()
1166
+ if act not in ("navigate", "screenshot", "click", "fill"):
1167
+ return json.dumps({
1168
+ "success": False,
1169
+ "error": f"Invalid browser action: '{action}'. Valid actions: navigate, screenshot, click, fill."
1170
+ }, ensure_ascii=False)
1171
+
1172
+ cmd = {"action": act}
1173
+ if url:
1174
+ cmd["url"] = str(url).strip()
1175
+ if selector:
1176
+ cmd["selector"] = str(selector).strip()
1177
+ if value is not None:
1178
+ cmd["value"] = str(value)
1179
+
1180
+ result = _BROWSER_SESSION.execute(cmd)
1181
+ return json.dumps(result, ensure_ascii=False, indent=2)
540
1182
  except Exception as e:
541
1183
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
542
1184
 
@@ -544,69 +1186,242 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
544
1186
  LOCAL_TOOLS_DEFINITIONS = [
545
1187
  {
546
1188
  "name": "list_directory",
547
- "description": "Lista archivos y carpetas en un directorio local.",
1189
+ "description": "List files and directories in a local directory with safe bounded recursive traversal.",
548
1190
  "inputSchema": {
549
1191
  "type": "object",
550
1192
  "properties": {
551
- "path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"},
552
- "max_depth": {"type": "integer", "description": "Profundidad máxima", "default": 1}
1193
+ "path": {"type": "string", "description": "Relative or absolute directory path (defaults to '.')"},
1194
+ "recursive": {"type": "boolean", "description": "If true, traverses subdirectories up to depth 3", "default": False}
553
1195
  }
554
1196
  }
555
1197
  },
556
1198
  {
557
1199
  "name": "read_file",
558
- "description": "Lee el contenido de texto de un archivo local.",
1200
+ "description": "Read text content from a local file with safe ranges and bounds.",
559
1201
  "inputSchema": {
560
1202
  "type": "object",
561
1203
  "properties": {
562
- "path": {"type": "string", "description": "Ruta del archivo"},
563
- "start_line": {"type": "integer", "description": "Línea inicial (1-indexed)", "default": 1},
564
- "max_lines": {"type": "integer", "description": "Número máximo de líneas a leer", "default": 500},
565
- "max_bytes": {"type": "integer", "description": "Límite máximo de bytes", "default": 100000}
1204
+ "path": {"type": "string", "description": "Path to the file to read"},
1205
+ "start_line": {"type": "integer", "description": "Starting line number (1-indexed)", "default": 1},
1206
+ "end_line": {"type": "integer", "description": "Ending line number (inclusive)"},
1207
+ "max_lines": {"type": "integer", "description": "Maximum number of lines to read when end_line is omitted", "default": 500},
1208
+ "max_bytes": {"type": "integer", "description": "Maximum byte budget", "default": 100000}
566
1209
  },
567
1210
  "required": ["path"]
568
1211
  }
569
1212
  },
570
1213
  {
571
- "name": "edit_file",
572
- "description": "Crea, sobrescribe o modifica un archivo local de forma atómica.",
1214
+ "name": "write_file",
1215
+ "description": "Create or overwrite a file completely and atomically.",
573
1216
  "inputSchema": {
574
1217
  "type": "object",
575
1218
  "properties": {
576
- "path": {"type": "string", "description": "Ruta del archivo a editar"},
577
- "content": {"type": "string", "description": "Contenido a escribir o reemplazar"},
578
- "mode": {"type": "string", "enum": ["write", "append", "replace_chunk"], "default": "write"},
579
- "target_content": {"type": "string", "description": "Texto exacto a reemplazar cuando mode='replace_chunk'"}
1219
+ "path": {"type": "string", "description": "Path to the file to write"},
1220
+ "content": {"type": "string", "description": "Full content of the file"}
580
1221
  },
581
1222
  "required": ["path", "content"]
582
1223
  }
583
1224
  },
1225
+ {
1226
+ "name": "edit_file",
1227
+ "description": "Edit an existing file surgically by replacing old_str with new_str (must match exactly once).",
1228
+ "inputSchema": {
1229
+ "type": "object",
1230
+ "properties": {
1231
+ "path": {"type": "string", "description": "Path to the file to edit"},
1232
+ "old_str": {"type": "string", "description": "Exact snippet to replace (must appear exactly once in the file)"},
1233
+ "new_str": {"type": "string", "description": "Replacement snippet"},
1234
+ "content": {"type": "string", "description": "Content to write in legacy/compatible mode"},
1235
+ "mode": {"type": "string", "enum": ["surgical", "write", "append", "replace_chunk"], "default": "surgical"},
1236
+ "target_content": {"type": "string", "description": "Exact target content in replace_chunk mode"}
1237
+ },
1238
+ "required": ["path"]
1239
+ }
1240
+ },
1241
+ {
1242
+ "name": "bash",
1243
+ "description": "Execute a command in an interactive persistent shell session (preserves cwd and exported environment variables across calls).",
1244
+ "inputSchema": {
1245
+ "type": "object",
1246
+ "properties": {
1247
+ "command": {"type": "string", "description": "Shell command to execute (e.g. npm test, git status)"},
1248
+ "timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 30)", "default": 30}
1249
+ },
1250
+ "required": ["command"]
1251
+ }
1252
+ },
1253
+ {
1254
+ "name": "search_files",
1255
+ "description": "Recursively search for plain text or regular expressions across project files.",
1256
+ "inputSchema": {
1257
+ "type": "object",
1258
+ "properties": {
1259
+ "query": {"type": "string", "description": "Plain text string or regular expression to search for"},
1260
+ "path": {"type": "string", "description": "Base search directory (defaults to '.')", "default": "."},
1261
+ "file_pattern": {"type": "string", "description": "Optional glob filter (e.g. *.js, *.py)"}
1262
+ },
1263
+ "required": ["query"]
1264
+ }
1265
+ },
584
1266
  {
585
1267
  "name": "execute_command",
586
- "description": "Ejecuta un comando en la shell del sistema y captura la salida.",
1268
+ "description": "Execute a shell command and capture stdout/stderr.",
587
1269
  "inputSchema": {
588
1270
  "type": "object",
589
1271
  "properties": {
590
- "command": {"type": "string", "description": "Comando a ejecutar"},
591
- "cwd": {"type": "string", "description": "Directorio de trabajo (por defecto '.')"},
592
- "timeout_seconds": {"type": "integer", "description": "Tiempo límite en segundos", "default": 60}
1272
+ "command": {"type": "string", "description": "Command to execute"},
1273
+ "cwd": {"type": "string", "description": "Working directory (defaults to '.')"},
1274
+ "timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 60)", "default": 60}
593
1275
  },
594
1276
  "required": ["command"]
595
1277
  }
1278
+ },
1279
+ {
1280
+ "name": "get_diagnostics",
1281
+ "description": "Retrieve syntax diagnostics and code errors for a file or the entire workspace.",
1282
+ "inputSchema": {
1283
+ "type": "object",
1284
+ "properties": {
1285
+ "path": {"type": "string", "description": "Path to the file or directory to inspect (defaults to '.')"}
1286
+ }
1287
+ }
1288
+ },
1289
+ {
1290
+ "name": "browser_action",
1291
+ "description": "Control a headless browser (Playwright) for web navigation, UI testing, and visual inspection via screenshots.",
1292
+ "inputSchema": {
1293
+ "type": "object",
1294
+ "properties": {
1295
+ "action": {
1296
+ "type": "string",
1297
+ "enum": ["navigate", "screenshot", "click", "fill"],
1298
+ "description": "Action to perform in the browser"
1299
+ },
1300
+ "url": {"type": "string", "description": "Target URL for navigate or screenshot"},
1301
+ "selector": {"type": "string", "description": "CSS selector for click or fill actions"},
1302
+ "value": {"type": "string", "description": "Text value to type for fill action"}
1303
+ },
1304
+ "required": ["action"]
1305
+ }
596
1306
  }
597
1307
  ]
598
1308
 
599
1309
  LOCAL_TOOL_HANDLERS = {
600
1310
  "list_directory": list_directory,
601
1311
  "read_file": read_file,
1312
+ "write_file": write_file,
602
1313
  "edit_file": edit_file,
603
- "execute_command": execute_command
1314
+ "bash": bash,
1315
+ "search_files": search_files,
1316
+ "execute_command": execute_command,
1317
+ "get_diagnostics": get_diagnostics,
1318
+ "browser_action": browser_action
604
1319
  }
605
1320
 
606
1321
  # ==============================================================================
607
1322
  # Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
608
1323
  # ==============================================================================
609
1324
 
1325
+ MAX_HTTP_BODY_BYTES = 1024 * 1024
1326
+ MAX_RPC_METHOD_LENGTH = 128
1327
+ MAX_TOOL_NAME_LENGTH = 128
1328
+ MAX_PATH_LENGTH = 4096
1329
+ MAX_COMMAND_LENGTH = 16384
1330
+
1331
+
1332
+ def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
1333
+ """Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
1334
+ schemas = {
1335
+ "list_directory": {
1336
+ "path": (str, MAX_PATH_LENGTH),
1337
+ "recursive": (bool, None)
1338
+ },
1339
+ "read_file": {
1340
+ "path": (str, MAX_PATH_LENGTH),
1341
+ "start_line": (int, None),
1342
+ "end_line": (int, None),
1343
+ "max_lines": (int, None),
1344
+ "max_bytes": (int, None)
1345
+ },
1346
+ "write_file": {
1347
+ "path": (str, MAX_PATH_LENGTH),
1348
+ "content": (str, MAX_HTTP_BODY_BYTES)
1349
+ },
1350
+ "edit_file": {
1351
+ "path": (str, MAX_PATH_LENGTH),
1352
+ "old_str": (str, MAX_HTTP_BODY_BYTES),
1353
+ "new_str": (str, MAX_HTTP_BODY_BYTES),
1354
+ "content": (str, MAX_HTTP_BODY_BYTES),
1355
+ "mode": (str, 32),
1356
+ "target_content": (str, MAX_HTTP_BODY_BYTES)
1357
+ },
1358
+ "bash": {
1359
+ "command": (str, MAX_COMMAND_LENGTH),
1360
+ "timeout_seconds": (int, None)
1361
+ },
1362
+ "search_files": {
1363
+ "query": (str, 4096),
1364
+ "path": (str, MAX_PATH_LENGTH),
1365
+ "file_pattern": (str, 256)
1366
+ },
1367
+ "execute_command": {
1368
+ "command": (str, MAX_COMMAND_LENGTH),
1369
+ "cwd": (str, MAX_PATH_LENGTH),
1370
+ "timeout_seconds": (int, None)
1371
+ },
1372
+ "get_diagnostics": {
1373
+ "path": (str, MAX_PATH_LENGTH)
1374
+ },
1375
+ "browser_action": {
1376
+ "action": (str, 32),
1377
+ "url": (str, 4096),
1378
+ "selector": (str, 1024),
1379
+ "value": (str, MAX_HTTP_BODY_BYTES)
1380
+ }
1381
+ }
1382
+ schema = schemas.get(tool_name)
1383
+ if schema is None:
1384
+ return None
1385
+
1386
+ for name, value in arguments.items():
1387
+ expected = schema.get(name)
1388
+ if expected is None:
1389
+ return f"Argumento no permitido: {name}"
1390
+ expected_type, max_length = expected
1391
+ if expected_type is bool:
1392
+ if not isinstance(value, bool):
1393
+ return f"Tipo inválido para '{name}'"
1394
+ else:
1395
+ if isinstance(value, bool) or not isinstance(value, expected_type):
1396
+ return f"Tipo inválido para '{name}'"
1397
+ if max_length is not None and len(value) > max_length:
1398
+ return f"'{name}' excede el tamaño máximo permitido"
1399
+
1400
+ required = {
1401
+ "read_file": ("path",),
1402
+ "write_file": ("path", "content"),
1403
+ "edit_file": ("path",),
1404
+ "bash": ("command",),
1405
+ "search_files": ("query",),
1406
+ "execute_command": ("command",),
1407
+ "browser_action": ("action",)
1408
+ }
1409
+ for name in required.get(tool_name, ()):
1410
+ if name not in arguments:
1411
+ return f"Falta el argumento obligatorio: {name}"
1412
+
1413
+ if tool_name == "edit_file":
1414
+ has_surgical = "old_str" in arguments and "new_str" in arguments
1415
+ has_legacy = "content" in arguments
1416
+ if not has_surgical and not has_legacy:
1417
+ return "Faltan argumentos obligatorios: especifica 'old_str' y 'new_str' o 'content'"
1418
+
1419
+ if tool_name == "browser_action":
1420
+ if arguments.get("action") not in ("navigate", "screenshot", "click", "fill"):
1421
+ return f"Acción de navegador no permitida: {arguments.get('action')}"
1422
+
1423
+ return None
1424
+
610
1425
  def sanitize_log_path(raw_path: str) -> str:
611
1426
  """Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
612
1427
  if not raw_path or "?" not in raw_path:
@@ -626,10 +1441,10 @@ def format_log_error(msg: str, max_len: int = 160) -> str:
626
1441
  return cleaned
627
1442
 
628
1443
 
629
- def is_allowed_origin(origin: str | None) -> bool:
1444
+ def is_allowed_origin(origin: str | None, require_origin: bool = False) -> bool:
630
1445
  """Verifica si el origen CORS está autorizado."""
631
1446
  if origin is None or origin == "null":
632
- return True
1447
+ return not require_origin
633
1448
  origin_lower = origin.lower()
634
1449
  if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
635
1450
  return True
@@ -1379,7 +2194,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1379
2194
 
1380
2195
  def send_cors_headers(self):
1381
2196
  origin = self.headers.get("Origin")
1382
- if not is_allowed_origin(origin):
2197
+ if not is_allowed_origin(origin, require_origin=True):
1383
2198
  return
1384
2199
  self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
1385
2200
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
@@ -1387,19 +2202,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1387
2202
  self.send_header("Access-Control-Allow-Private-Network", "true")
1388
2203
 
1389
2204
  def verify_token(self) -> bool:
1390
- """Comprueba el token efímero de sesión en cabeceras o query string."""
2205
+ """Comprueba el token de sesión exclusivamente en cabeceras HTTP."""
1391
2206
  auth_header = self.headers.get("Authorization", "")
1392
2207
  token_candidate = None
1393
2208
  if auth_header.startswith("Bearer "):
1394
2209
  token_candidate = auth_header[7:].strip()
1395
2210
  elif "X-ZeroChat-Token" in self.headers:
1396
2211
  token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
1397
- elif "?" in self.path:
1398
- query = self.path.split("?", 1)[1]
1399
- for part in query.split("&"):
1400
- if part.startswith("token="):
1401
- token_candidate = part.split("=", 1)[1].strip()
1402
- break
1403
2212
 
1404
2213
  if not token_candidate:
1405
2214
  return False
@@ -1416,7 +2225,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1416
2225
  if not is_heartbeat:
1417
2226
  self._log_req("OPTIONS", safe_path)
1418
2227
  origin = self.headers.get("Origin")
1419
- if not is_allowed_origin(origin):
2228
+ if not is_allowed_origin(origin, require_origin=True):
1420
2229
  self.send_response(403)
1421
2230
  self.end_headers()
1422
2231
  self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
@@ -1534,8 +2343,31 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1534
2343
  self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
1535
2344
  return
1536
2345
 
1537
- content_len = int(self.headers.get("Content-Length", 0))
1538
- post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
2346
+ raw_content_len = self.headers.get("Content-Length")
2347
+ try:
2348
+ content_len = int(raw_content_len)
2349
+ except (TypeError, ValueError):
2350
+ self._log_req("POST", safe_path)
2351
+ self._send_json_response(400, {"error": "Invalid Content-Length"})
2352
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length inválido")
2353
+ return
2354
+ if content_len < 0:
2355
+ self._log_req("POST", safe_path)
2356
+ self._send_json_response(400, {"error": "Invalid Content-Length"})
2357
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length negativo")
2358
+ return
2359
+ if content_len > MAX_HTTP_BODY_BYTES:
2360
+ self._log_req("POST", safe_path)
2361
+ if content_len <= MAX_HTTP_BODY_BYTES * 2:
2362
+ try:
2363
+ self.rfile.read(content_len)
2364
+ except Exception:
2365
+ pass
2366
+ self.close_connection = True
2367
+ self._send_json_response(413, {"error": "Request body too large"})
2368
+ self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
2369
+ return
2370
+ post_data = self.rfile.read(content_len)
1539
2371
 
1540
2372
  try:
1541
2373
  req = json.loads(post_data.decode("utf-8"))
@@ -1554,10 +2386,49 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1554
2386
  self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
1555
2387
  return
1556
2388
 
1557
- req_path = self.path.split("?", 1)[0].rstrip("/")
2389
+ if not isinstance(req, dict):
2390
+ self._send_json_response(400, {"error": "Request must be a JSON object"})
2391
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "La solicitud JSON no es un objeto")
2392
+ return
2393
+
1558
2394
  req_id = req.get("id")
1559
2395
  method = req.get("method")
1560
2396
  params = req.get("params", {})
2397
+ if req.get("jsonrpc") != "2.0":
2398
+ self._send_json_response(400, {"error": "Invalid JSON-RPC version"})
2399
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Versión JSON-RPC inválida")
2400
+ return
2401
+ if req_id is not None and (isinstance(req_id, bool) or not isinstance(req_id, (str, int, float))):
2402
+ self._send_json_response(400, {"error": "Invalid JSON-RPC id"})
2403
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "id JSON-RPC inválido")
2404
+ return
2405
+ if not isinstance(method, str) or not method or len(method) > MAX_RPC_METHOD_LENGTH:
2406
+ self._send_json_response(400, {"error": "Invalid JSON-RPC method"})
2407
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Método JSON-RPC inválido")
2408
+ return
2409
+ if not isinstance(params, dict):
2410
+ self._send_json_response(400, {"error": "Invalid JSON-RPC params"})
2411
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "params JSON-RPC inválidos")
2412
+ return
2413
+
2414
+ if method == "tools/call":
2415
+ tool_name = params.get("name")
2416
+ tool_args = params.get("arguments", {})
2417
+ if not isinstance(tool_name, str) or not tool_name or len(tool_name) > MAX_TOOL_NAME_LENGTH:
2418
+ self._send_json_response(400, {"error": "Invalid tool name"})
2419
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Nombre de herramienta inválido")
2420
+ return
2421
+ if not isinstance(tool_args, dict):
2422
+ self._send_json_response(400, {"error": "Invalid tool arguments"})
2423
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Argumentos de herramienta inválidos")
2424
+ return
2425
+ tool_args_error = validate_local_tool_arguments(tool_name, tool_args)
2426
+ if tool_args_error:
2427
+ self._send_json_response(400, {"error": tool_args_error})
2428
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, tool_args_error)
2429
+ return
2430
+
2431
+ req_path = self.path.split("?", 1)[0].rstrip("/")
1561
2432
 
1562
2433
  # Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
1563
2434
  tool_name = params.get("name", "") if isinstance(params, dict) else ""
File without changes
File without changes
File without changes