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.
- {zerochat-7.8.0/zerochat.egg-info → zerochat-7.10.0}/PKG-INFO +1 -1
- {zerochat-7.8.0 → zerochat-7.10.0}/pyproject.toml +1 -1
- {zerochat-7.8.0 → zerochat-7.10.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-7.8.0 → zerochat-7.10.0}/zerochat.py +846 -72
- {zerochat-7.8.0 → zerochat-7.10.0}/LICENSE +0 -0
- {zerochat-7.8.0 → zerochat-7.10.0}/README.md +0 -0
- {zerochat-7.8.0 → zerochat-7.10.0}/setup.cfg +0 -0
- {zerochat-7.8.0 → zerochat-7.10.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-7.8.0 → zerochat-7.10.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-7.8.0 → zerochat-7.10.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-7.8.0 → zerochat-7.10.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
-
"""
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
470
|
-
"""
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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":
|
|
535
|
-
"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"
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
562
|
-
"start_line": {"type": "integer", "description": "
|
|
563
|
-
"
|
|
564
|
-
"
|
|
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": "
|
|
571
|
-
"description": "
|
|
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": "
|
|
576
|
-
"content": {"type": "string", "description": "
|
|
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": "
|
|
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": "
|
|
590
|
-
"cwd": {"type": "string", "description": "
|
|
591
|
-
"timeout_seconds": {"type": "integer", "description": "
|
|
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
|
-
"
|
|
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": {
|
|
1335
|
+
"list_directory": {
|
|
1336
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1337
|
+
"recursive": (bool, None)
|
|
1338
|
+
},
|
|
620
1339
|
"read_file": {
|
|
621
|
-
"path": (str, MAX_PATH_LENGTH),
|
|
622
|
-
"
|
|
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),
|
|
626
|
-
"
|
|
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),
|
|
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
|
|
643
|
-
|
|
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
|
-
"
|
|
650
|
-
"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|