zerochat 7.8.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.8.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.8.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.8.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.8.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 = ".") -> str:
392
- """Lista los archivos y carpetas de un directorio, sin recorrer subdirectorios."""
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 = ".") -> 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)
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)
474
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,62 +1186,136 @@ 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 de un directorio local, sin recorrer subdirectorios.",
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 '.')"}
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}
552
1195
  }
553
1196
  }
554
1197
  },
555
1198
  {
556
1199
  "name": "read_file",
557
- "description": "Lee el contenido de texto de un archivo local.",
1200
+ "description": "Read text content from a local file with safe ranges and bounds.",
558
1201
  "inputSchema": {
559
1202
  "type": "object",
560
1203
  "properties": {
561
- "path": {"type": "string", "description": "Ruta del archivo"},
562
- "start_line": {"type": "integer", "description": "Línea inicial (1-indexed)", "default": 1},
563
- "max_lines": {"type": "integer", "description": "Número máximo de líneas a leer", "default": 500},
564
- "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}
565
1209
  },
566
1210
  "required": ["path"]
567
1211
  }
568
1212
  },
569
1213
  {
570
- "name": "edit_file",
571
- "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.",
572
1216
  "inputSchema": {
573
1217
  "type": "object",
574
1218
  "properties": {
575
- "path": {"type": "string", "description": "Ruta del archivo a editar"},
576
- "content": {"type": "string", "description": "Contenido a escribir o reemplazar"},
577
- "mode": {"type": "string", "enum": ["write", "append", "replace_chunk"], "default": "write"},
578
- "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"}
579
1221
  },
580
1222
  "required": ["path", "content"]
581
1223
  }
582
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
+ },
583
1266
  {
584
1267
  "name": "execute_command",
585
- "description": "Ejecuta un comando en la shell del sistema y captura la salida.",
1268
+ "description": "Execute a shell command and capture stdout/stderr.",
586
1269
  "inputSchema": {
587
1270
  "type": "object",
588
1271
  "properties": {
589
- "command": {"type": "string", "description": "Comando a ejecutar"},
590
- "cwd": {"type": "string", "description": "Directorio de trabajo (por defecto '.')"},
591
- "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}
592
1275
  },
593
1276
  "required": ["command"]
594
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
+ }
595
1306
  }
596
1307
  ]
597
1308
 
598
1309
  LOCAL_TOOL_HANDLERS = {
599
1310
  "list_directory": list_directory,
600
1311
  "read_file": read_file,
1312
+ "write_file": write_file,
601
1313
  "edit_file": edit_file,
602
- "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
603
1319
  }
604
1320
 
605
1321
  # ==============================================================================
@@ -616,18 +1332,51 @@ MAX_COMMAND_LENGTH = 16384
616
1332
  def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
617
1333
  """Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
618
1334
  schemas = {
619
- "list_directory": {"path": (str, MAX_PATH_LENGTH)},
1335
+ "list_directory": {
1336
+ "path": (str, MAX_PATH_LENGTH),
1337
+ "recursive": (bool, None)
1338
+ },
620
1339
  "read_file": {
621
- "path": (str, MAX_PATH_LENGTH), "start_line": (int, None),
622
- "max_lines": (int, None), "max_bytes": (int, None)
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)
623
1349
  },
624
1350
  "edit_file": {
625
- "path": (str, MAX_PATH_LENGTH), "content": (str, MAX_HTTP_BODY_BYTES),
626
- "mode": (str, 32), "target_content": (str, MAX_HTTP_BODY_BYTES)
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)
627
1366
  },
628
1367
  "execute_command": {
629
- "command": (str, MAX_COMMAND_LENGTH), "cwd": (str, MAX_PATH_LENGTH),
1368
+ "command": (str, MAX_COMMAND_LENGTH),
1369
+ "cwd": (str, MAX_PATH_LENGTH),
630
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)
631
1380
  }
632
1381
  }
633
1382
  schema = schemas.get(tool_name)
@@ -639,19 +1388,38 @@ def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None
639
1388
  if expected is None:
640
1389
  return f"Argumento no permitido: {name}"
641
1390
  expected_type, max_length = expected
642
- if isinstance(value, bool) or not isinstance(value, expected_type):
643
- return f"Tipo inválido para '{name}'"
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}'"
644
1397
  if max_length is not None and len(value) > max_length:
645
1398
  return f"'{name}' excede el tamaño máximo permitido"
646
1399
 
647
1400
  required = {
648
1401
  "read_file": ("path",),
649
- "edit_file": ("path", "content"),
650
- "execute_command": ("command",)
1402
+ "write_file": ("path", "content"),
1403
+ "edit_file": ("path",),
1404
+ "bash": ("command",),
1405
+ "search_files": ("query",),
1406
+ "execute_command": ("command",),
1407
+ "browser_action": ("action",)
651
1408
  }
652
1409
  for name in required.get(tool_name, ()):
653
1410
  if name not in arguments:
654
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
+
655
1423
  return None
656
1424
 
657
1425
  def sanitize_log_path(raw_path: str) -> str:
@@ -1590,6 +2358,12 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1590
2358
  return
1591
2359
  if content_len > MAX_HTTP_BODY_BYTES:
1592
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
1593
2367
  self._send_json_response(413, {"error": "Request body too large"})
1594
2368
  self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
1595
2369
  return
File without changes
File without changes
File without changes