zerochat 7.7.0__tar.gz → 7.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {zerochat-7.7.0/zerochat.egg-info → zerochat-7.10.0}/PKG-INFO +1 -1
- {zerochat-7.7.0 → zerochat-7.10.0}/pyproject.toml +1 -1
- {zerochat-7.7.0 → zerochat-7.10.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-7.7.0 → zerochat-7.10.0}/zerochat.py +948 -77
- {zerochat-7.7.0 → zerochat-7.10.0}/LICENSE +0 -0
- {zerochat-7.7.0 → zerochat-7.10.0}/README.md +0 -0
- {zerochat-7.7.0 → zerochat-7.10.0}/setup.cfg +0 -0
- {zerochat-7.7.0 → zerochat-7.10.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-7.7.0 → zerochat-7.10.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-7.7.0 → zerochat-7.10.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-7.7.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 = ".",
|
|
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 = ".", max_depth: int = 1) -> str:
|
|
|
423
446
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
424
447
|
|
|
425
448
|
|
|
426
|
-
def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: int = 100000) -> str:
|
|
427
|
-
"""
|
|
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)
|
|
474
541
|
|
|
542
|
+
|
|
543
|
+
def edit_file(path: str, old_str: str = None, new_str: str = None, content: str = None, mode: str = "surgical", target_content: str = None) -> str:
|
|
544
|
+
"""Modify an existing file surgically or atomically."""
|
|
545
|
+
try:
|
|
546
|
+
target = Path(path).expanduser().resolve()
|
|
547
|
+
|
|
548
|
+
# Preferred surgical mode (old_str -> new_str)
|
|
549
|
+
if old_str is not None and new_str is not None:
|
|
550
|
+
if not target.exists():
|
|
551
|
+
return json.dumps({"success": False, "error": f"File '{path}' does not exist. Use read_file to verify existing paths."}, ensure_ascii=False)
|
|
552
|
+
if not target.is_file():
|
|
553
|
+
return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
|
|
554
|
+
|
|
555
|
+
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
556
|
+
file_text = f.read()
|
|
557
|
+
|
|
558
|
+
occurrences = file_text.count(old_str)
|
|
559
|
+
if occurrences == 0:
|
|
560
|
+
return json.dumps({
|
|
561
|
+
"success": False,
|
|
562
|
+
"error": f"Target text was not found in '{path}'. Check exact file content with read_file."
|
|
563
|
+
}, ensure_ascii=False)
|
|
564
|
+
if occurrences > 1:
|
|
565
|
+
return json.dumps({
|
|
566
|
+
"success": False,
|
|
567
|
+
"error": f"Found {occurrences} matches for the snippet in '{path}'. Provide more surrounding context in old_str to ensure a unique match."
|
|
568
|
+
}, ensure_ascii=False)
|
|
569
|
+
|
|
570
|
+
updated_text = file_text.replace(old_str, new_str, 1)
|
|
571
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
|
|
572
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
573
|
+
f.write(updated_text)
|
|
574
|
+
temp_path.replace(target)
|
|
575
|
+
return json.dumps({
|
|
576
|
+
"success": True,
|
|
577
|
+
"path": str(target),
|
|
578
|
+
"mode": "surgical",
|
|
579
|
+
"bytes_written": len(updated_text.encode("utf-8")),
|
|
580
|
+
"replacements": 1
|
|
581
|
+
}, ensure_ascii=False, indent=2)
|
|
582
|
+
|
|
583
|
+
# Legacy modes (write, append, replace_chunk)
|
|
584
|
+
if content is None:
|
|
585
|
+
return json.dumps({"success": False, "error": "Must provide old_str and new_str for surgical edit, or content for compatible modes."}, ensure_ascii=False)
|
|
586
|
+
|
|
587
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
475
588
|
if mode == "append":
|
|
476
589
|
with open(target, "a", encoding="utf-8") as f:
|
|
477
590
|
f.write(content)
|
|
478
591
|
bytes_written = len(content.encode("utf-8"))
|
|
479
592
|
elif mode == "replace_chunk":
|
|
480
593
|
if not target.exists():
|
|
481
|
-
return json.dumps({"success": False, "error": f"
|
|
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,69 +1186,242 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
|
|
|
544
1186
|
LOCAL_TOOLS_DEFINITIONS = [
|
|
545
1187
|
{
|
|
546
1188
|
"name": "list_directory",
|
|
547
|
-
"description": "
|
|
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": "
|
|
552
|
-
"
|
|
1193
|
+
"path": {"type": "string", "description": "Relative or absolute directory path (defaults to '.')"},
|
|
1194
|
+
"recursive": {"type": "boolean", "description": "If true, traverses subdirectories up to depth 3", "default": False}
|
|
553
1195
|
}
|
|
554
1196
|
}
|
|
555
1197
|
},
|
|
556
1198
|
{
|
|
557
1199
|
"name": "read_file",
|
|
558
|
-
"description": "
|
|
1200
|
+
"description": "Read text content from a local file with safe ranges and bounds.",
|
|
559
1201
|
"inputSchema": {
|
|
560
1202
|
"type": "object",
|
|
561
1203
|
"properties": {
|
|
562
|
-
"path": {"type": "string", "description": "
|
|
563
|
-
"start_line": {"type": "integer", "description": "
|
|
564
|
-
"
|
|
565
|
-
"
|
|
1204
|
+
"path": {"type": "string", "description": "Path to the file to read"},
|
|
1205
|
+
"start_line": {"type": "integer", "description": "Starting line number (1-indexed)", "default": 1},
|
|
1206
|
+
"end_line": {"type": "integer", "description": "Ending line number (inclusive)"},
|
|
1207
|
+
"max_lines": {"type": "integer", "description": "Maximum number of lines to read when end_line is omitted", "default": 500},
|
|
1208
|
+
"max_bytes": {"type": "integer", "description": "Maximum byte budget", "default": 100000}
|
|
566
1209
|
},
|
|
567
1210
|
"required": ["path"]
|
|
568
1211
|
}
|
|
569
1212
|
},
|
|
570
1213
|
{
|
|
571
|
-
"name": "
|
|
572
|
-
"description": "
|
|
1214
|
+
"name": "write_file",
|
|
1215
|
+
"description": "Create or overwrite a file completely and atomically.",
|
|
573
1216
|
"inputSchema": {
|
|
574
1217
|
"type": "object",
|
|
575
1218
|
"properties": {
|
|
576
|
-
"path": {"type": "string", "description": "
|
|
577
|
-
"content": {"type": "string", "description": "
|
|
578
|
-
"mode": {"type": "string", "enum": ["write", "append", "replace_chunk"], "default": "write"},
|
|
579
|
-
"target_content": {"type": "string", "description": "Texto exacto a reemplazar cuando mode='replace_chunk'"}
|
|
1219
|
+
"path": {"type": "string", "description": "Path to the file to write"},
|
|
1220
|
+
"content": {"type": "string", "description": "Full content of the file"}
|
|
580
1221
|
},
|
|
581
1222
|
"required": ["path", "content"]
|
|
582
1223
|
}
|
|
583
1224
|
},
|
|
1225
|
+
{
|
|
1226
|
+
"name": "edit_file",
|
|
1227
|
+
"description": "Edit an existing file surgically by replacing old_str with new_str (must match exactly once).",
|
|
1228
|
+
"inputSchema": {
|
|
1229
|
+
"type": "object",
|
|
1230
|
+
"properties": {
|
|
1231
|
+
"path": {"type": "string", "description": "Path to the file to edit"},
|
|
1232
|
+
"old_str": {"type": "string", "description": "Exact snippet to replace (must appear exactly once in the file)"},
|
|
1233
|
+
"new_str": {"type": "string", "description": "Replacement snippet"},
|
|
1234
|
+
"content": {"type": "string", "description": "Content to write in legacy/compatible mode"},
|
|
1235
|
+
"mode": {"type": "string", "enum": ["surgical", "write", "append", "replace_chunk"], "default": "surgical"},
|
|
1236
|
+
"target_content": {"type": "string", "description": "Exact target content in replace_chunk mode"}
|
|
1237
|
+
},
|
|
1238
|
+
"required": ["path"]
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
{
|
|
1242
|
+
"name": "bash",
|
|
1243
|
+
"description": "Execute a command in an interactive persistent shell session (preserves cwd and exported environment variables across calls).",
|
|
1244
|
+
"inputSchema": {
|
|
1245
|
+
"type": "object",
|
|
1246
|
+
"properties": {
|
|
1247
|
+
"command": {"type": "string", "description": "Shell command to execute (e.g. npm test, git status)"},
|
|
1248
|
+
"timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 30)", "default": 30}
|
|
1249
|
+
},
|
|
1250
|
+
"required": ["command"]
|
|
1251
|
+
}
|
|
1252
|
+
},
|
|
1253
|
+
{
|
|
1254
|
+
"name": "search_files",
|
|
1255
|
+
"description": "Recursively search for plain text or regular expressions across project files.",
|
|
1256
|
+
"inputSchema": {
|
|
1257
|
+
"type": "object",
|
|
1258
|
+
"properties": {
|
|
1259
|
+
"query": {"type": "string", "description": "Plain text string or regular expression to search for"},
|
|
1260
|
+
"path": {"type": "string", "description": "Base search directory (defaults to '.')", "default": "."},
|
|
1261
|
+
"file_pattern": {"type": "string", "description": "Optional glob filter (e.g. *.js, *.py)"}
|
|
1262
|
+
},
|
|
1263
|
+
"required": ["query"]
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
584
1266
|
{
|
|
585
1267
|
"name": "execute_command",
|
|
586
|
-
"description": "
|
|
1268
|
+
"description": "Execute a shell command and capture stdout/stderr.",
|
|
587
1269
|
"inputSchema": {
|
|
588
1270
|
"type": "object",
|
|
589
1271
|
"properties": {
|
|
590
|
-
"command": {"type": "string", "description": "
|
|
591
|
-
"cwd": {"type": "string", "description": "
|
|
592
|
-
"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}
|
|
593
1275
|
},
|
|
594
1276
|
"required": ["command"]
|
|
595
1277
|
}
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
"name": "get_diagnostics",
|
|
1281
|
+
"description": "Retrieve syntax diagnostics and code errors for a file or the entire workspace.",
|
|
1282
|
+
"inputSchema": {
|
|
1283
|
+
"type": "object",
|
|
1284
|
+
"properties": {
|
|
1285
|
+
"path": {"type": "string", "description": "Path to the file or directory to inspect (defaults to '.')"}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
"name": "browser_action",
|
|
1291
|
+
"description": "Control a headless browser (Playwright) for web navigation, UI testing, and visual inspection via screenshots.",
|
|
1292
|
+
"inputSchema": {
|
|
1293
|
+
"type": "object",
|
|
1294
|
+
"properties": {
|
|
1295
|
+
"action": {
|
|
1296
|
+
"type": "string",
|
|
1297
|
+
"enum": ["navigate", "screenshot", "click", "fill"],
|
|
1298
|
+
"description": "Action to perform in the browser"
|
|
1299
|
+
},
|
|
1300
|
+
"url": {"type": "string", "description": "Target URL for navigate or screenshot"},
|
|
1301
|
+
"selector": {"type": "string", "description": "CSS selector for click or fill actions"},
|
|
1302
|
+
"value": {"type": "string", "description": "Text value to type for fill action"}
|
|
1303
|
+
},
|
|
1304
|
+
"required": ["action"]
|
|
1305
|
+
}
|
|
596
1306
|
}
|
|
597
1307
|
]
|
|
598
1308
|
|
|
599
1309
|
LOCAL_TOOL_HANDLERS = {
|
|
600
1310
|
"list_directory": list_directory,
|
|
601
1311
|
"read_file": read_file,
|
|
1312
|
+
"write_file": write_file,
|
|
602
1313
|
"edit_file": edit_file,
|
|
603
|
-
"
|
|
1314
|
+
"bash": bash,
|
|
1315
|
+
"search_files": search_files,
|
|
1316
|
+
"execute_command": execute_command,
|
|
1317
|
+
"get_diagnostics": get_diagnostics,
|
|
1318
|
+
"browser_action": browser_action
|
|
604
1319
|
}
|
|
605
1320
|
|
|
606
1321
|
# ==============================================================================
|
|
607
1322
|
# Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
|
|
608
1323
|
# ==============================================================================
|
|
609
1324
|
|
|
1325
|
+
MAX_HTTP_BODY_BYTES = 1024 * 1024
|
|
1326
|
+
MAX_RPC_METHOD_LENGTH = 128
|
|
1327
|
+
MAX_TOOL_NAME_LENGTH = 128
|
|
1328
|
+
MAX_PATH_LENGTH = 4096
|
|
1329
|
+
MAX_COMMAND_LENGTH = 16384
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
|
|
1333
|
+
"""Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
|
|
1334
|
+
schemas = {
|
|
1335
|
+
"list_directory": {
|
|
1336
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1337
|
+
"recursive": (bool, None)
|
|
1338
|
+
},
|
|
1339
|
+
"read_file": {
|
|
1340
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1341
|
+
"start_line": (int, None),
|
|
1342
|
+
"end_line": (int, None),
|
|
1343
|
+
"max_lines": (int, None),
|
|
1344
|
+
"max_bytes": (int, None)
|
|
1345
|
+
},
|
|
1346
|
+
"write_file": {
|
|
1347
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1348
|
+
"content": (str, MAX_HTTP_BODY_BYTES)
|
|
1349
|
+
},
|
|
1350
|
+
"edit_file": {
|
|
1351
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1352
|
+
"old_str": (str, MAX_HTTP_BODY_BYTES),
|
|
1353
|
+
"new_str": (str, MAX_HTTP_BODY_BYTES),
|
|
1354
|
+
"content": (str, MAX_HTTP_BODY_BYTES),
|
|
1355
|
+
"mode": (str, 32),
|
|
1356
|
+
"target_content": (str, MAX_HTTP_BODY_BYTES)
|
|
1357
|
+
},
|
|
1358
|
+
"bash": {
|
|
1359
|
+
"command": (str, MAX_COMMAND_LENGTH),
|
|
1360
|
+
"timeout_seconds": (int, None)
|
|
1361
|
+
},
|
|
1362
|
+
"search_files": {
|
|
1363
|
+
"query": (str, 4096),
|
|
1364
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1365
|
+
"file_pattern": (str, 256)
|
|
1366
|
+
},
|
|
1367
|
+
"execute_command": {
|
|
1368
|
+
"command": (str, MAX_COMMAND_LENGTH),
|
|
1369
|
+
"cwd": (str, MAX_PATH_LENGTH),
|
|
1370
|
+
"timeout_seconds": (int, None)
|
|
1371
|
+
},
|
|
1372
|
+
"get_diagnostics": {
|
|
1373
|
+
"path": (str, MAX_PATH_LENGTH)
|
|
1374
|
+
},
|
|
1375
|
+
"browser_action": {
|
|
1376
|
+
"action": (str, 32),
|
|
1377
|
+
"url": (str, 4096),
|
|
1378
|
+
"selector": (str, 1024),
|
|
1379
|
+
"value": (str, MAX_HTTP_BODY_BYTES)
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
schema = schemas.get(tool_name)
|
|
1383
|
+
if schema is None:
|
|
1384
|
+
return None
|
|
1385
|
+
|
|
1386
|
+
for name, value in arguments.items():
|
|
1387
|
+
expected = schema.get(name)
|
|
1388
|
+
if expected is None:
|
|
1389
|
+
return f"Argumento no permitido: {name}"
|
|
1390
|
+
expected_type, max_length = expected
|
|
1391
|
+
if expected_type is bool:
|
|
1392
|
+
if not isinstance(value, bool):
|
|
1393
|
+
return f"Tipo inválido para '{name}'"
|
|
1394
|
+
else:
|
|
1395
|
+
if isinstance(value, bool) or not isinstance(value, expected_type):
|
|
1396
|
+
return f"Tipo inválido para '{name}'"
|
|
1397
|
+
if max_length is not None and len(value) > max_length:
|
|
1398
|
+
return f"'{name}' excede el tamaño máximo permitido"
|
|
1399
|
+
|
|
1400
|
+
required = {
|
|
1401
|
+
"read_file": ("path",),
|
|
1402
|
+
"write_file": ("path", "content"),
|
|
1403
|
+
"edit_file": ("path",),
|
|
1404
|
+
"bash": ("command",),
|
|
1405
|
+
"search_files": ("query",),
|
|
1406
|
+
"execute_command": ("command",),
|
|
1407
|
+
"browser_action": ("action",)
|
|
1408
|
+
}
|
|
1409
|
+
for name in required.get(tool_name, ()):
|
|
1410
|
+
if name not in arguments:
|
|
1411
|
+
return f"Falta el argumento obligatorio: {name}"
|
|
1412
|
+
|
|
1413
|
+
if tool_name == "edit_file":
|
|
1414
|
+
has_surgical = "old_str" in arguments and "new_str" in arguments
|
|
1415
|
+
has_legacy = "content" in arguments
|
|
1416
|
+
if not has_surgical and not has_legacy:
|
|
1417
|
+
return "Faltan argumentos obligatorios: especifica 'old_str' y 'new_str' o 'content'"
|
|
1418
|
+
|
|
1419
|
+
if tool_name == "browser_action":
|
|
1420
|
+
if arguments.get("action") not in ("navigate", "screenshot", "click", "fill"):
|
|
1421
|
+
return f"Acción de navegador no permitida: {arguments.get('action')}"
|
|
1422
|
+
|
|
1423
|
+
return None
|
|
1424
|
+
|
|
610
1425
|
def sanitize_log_path(raw_path: str) -> str:
|
|
611
1426
|
"""Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
|
|
612
1427
|
if not raw_path or "?" not in raw_path:
|
|
@@ -626,10 +1441,10 @@ def format_log_error(msg: str, max_len: int = 160) -> str:
|
|
|
626
1441
|
return cleaned
|
|
627
1442
|
|
|
628
1443
|
|
|
629
|
-
def is_allowed_origin(origin: str | None) -> bool:
|
|
1444
|
+
def is_allowed_origin(origin: str | None, require_origin: bool = False) -> bool:
|
|
630
1445
|
"""Verifica si el origen CORS está autorizado."""
|
|
631
1446
|
if origin is None or origin == "null":
|
|
632
|
-
return
|
|
1447
|
+
return not require_origin
|
|
633
1448
|
origin_lower = origin.lower()
|
|
634
1449
|
if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
|
|
635
1450
|
return True
|
|
@@ -1379,7 +2194,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1379
2194
|
|
|
1380
2195
|
def send_cors_headers(self):
|
|
1381
2196
|
origin = self.headers.get("Origin")
|
|
1382
|
-
if not is_allowed_origin(origin):
|
|
2197
|
+
if not is_allowed_origin(origin, require_origin=True):
|
|
1383
2198
|
return
|
|
1384
2199
|
self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
|
|
1385
2200
|
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
@@ -1387,19 +2202,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1387
2202
|
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
1388
2203
|
|
|
1389
2204
|
def verify_token(self) -> bool:
|
|
1390
|
-
"""Comprueba el token
|
|
2205
|
+
"""Comprueba el token de sesión exclusivamente en cabeceras HTTP."""
|
|
1391
2206
|
auth_header = self.headers.get("Authorization", "")
|
|
1392
2207
|
token_candidate = None
|
|
1393
2208
|
if auth_header.startswith("Bearer "):
|
|
1394
2209
|
token_candidate = auth_header[7:].strip()
|
|
1395
2210
|
elif "X-ZeroChat-Token" in self.headers:
|
|
1396
2211
|
token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
|
|
1397
|
-
elif "?" in self.path:
|
|
1398
|
-
query = self.path.split("?", 1)[1]
|
|
1399
|
-
for part in query.split("&"):
|
|
1400
|
-
if part.startswith("token="):
|
|
1401
|
-
token_candidate = part.split("=", 1)[1].strip()
|
|
1402
|
-
break
|
|
1403
2212
|
|
|
1404
2213
|
if not token_candidate:
|
|
1405
2214
|
return False
|
|
@@ -1416,7 +2225,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1416
2225
|
if not is_heartbeat:
|
|
1417
2226
|
self._log_req("OPTIONS", safe_path)
|
|
1418
2227
|
origin = self.headers.get("Origin")
|
|
1419
|
-
if not is_allowed_origin(origin):
|
|
2228
|
+
if not is_allowed_origin(origin, require_origin=True):
|
|
1420
2229
|
self.send_response(403)
|
|
1421
2230
|
self.end_headers()
|
|
1422
2231
|
self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
|
|
@@ -1534,8 +2343,31 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1534
2343
|
self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
|
|
1535
2344
|
return
|
|
1536
2345
|
|
|
1537
|
-
|
|
1538
|
-
|
|
2346
|
+
raw_content_len = self.headers.get("Content-Length")
|
|
2347
|
+
try:
|
|
2348
|
+
content_len = int(raw_content_len)
|
|
2349
|
+
except (TypeError, ValueError):
|
|
2350
|
+
self._log_req("POST", safe_path)
|
|
2351
|
+
self._send_json_response(400, {"error": "Invalid Content-Length"})
|
|
2352
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length inválido")
|
|
2353
|
+
return
|
|
2354
|
+
if content_len < 0:
|
|
2355
|
+
self._log_req("POST", safe_path)
|
|
2356
|
+
self._send_json_response(400, {"error": "Invalid Content-Length"})
|
|
2357
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length negativo")
|
|
2358
|
+
return
|
|
2359
|
+
if content_len > MAX_HTTP_BODY_BYTES:
|
|
2360
|
+
self._log_req("POST", safe_path)
|
|
2361
|
+
if content_len <= MAX_HTTP_BODY_BYTES * 2:
|
|
2362
|
+
try:
|
|
2363
|
+
self.rfile.read(content_len)
|
|
2364
|
+
except Exception:
|
|
2365
|
+
pass
|
|
2366
|
+
self.close_connection = True
|
|
2367
|
+
self._send_json_response(413, {"error": "Request body too large"})
|
|
2368
|
+
self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
|
|
2369
|
+
return
|
|
2370
|
+
post_data = self.rfile.read(content_len)
|
|
1539
2371
|
|
|
1540
2372
|
try:
|
|
1541
2373
|
req = json.loads(post_data.decode("utf-8"))
|
|
@@ -1554,10 +2386,49 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1554
2386
|
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
|
|
1555
2387
|
return
|
|
1556
2388
|
|
|
1557
|
-
|
|
2389
|
+
if not isinstance(req, dict):
|
|
2390
|
+
self._send_json_response(400, {"error": "Request must be a JSON object"})
|
|
2391
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "La solicitud JSON no es un objeto")
|
|
2392
|
+
return
|
|
2393
|
+
|
|
1558
2394
|
req_id = req.get("id")
|
|
1559
2395
|
method = req.get("method")
|
|
1560
2396
|
params = req.get("params", {})
|
|
2397
|
+
if req.get("jsonrpc") != "2.0":
|
|
2398
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC version"})
|
|
2399
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Versión JSON-RPC inválida")
|
|
2400
|
+
return
|
|
2401
|
+
if req_id is not None and (isinstance(req_id, bool) or not isinstance(req_id, (str, int, float))):
|
|
2402
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC id"})
|
|
2403
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "id JSON-RPC inválido")
|
|
2404
|
+
return
|
|
2405
|
+
if not isinstance(method, str) or not method or len(method) > MAX_RPC_METHOD_LENGTH:
|
|
2406
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC method"})
|
|
2407
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Método JSON-RPC inválido")
|
|
2408
|
+
return
|
|
2409
|
+
if not isinstance(params, dict):
|
|
2410
|
+
self._send_json_response(400, {"error": "Invalid JSON-RPC params"})
|
|
2411
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "params JSON-RPC inválidos")
|
|
2412
|
+
return
|
|
2413
|
+
|
|
2414
|
+
if method == "tools/call":
|
|
2415
|
+
tool_name = params.get("name")
|
|
2416
|
+
tool_args = params.get("arguments", {})
|
|
2417
|
+
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > MAX_TOOL_NAME_LENGTH:
|
|
2418
|
+
self._send_json_response(400, {"error": "Invalid tool name"})
|
|
2419
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Nombre de herramienta inválido")
|
|
2420
|
+
return
|
|
2421
|
+
if not isinstance(tool_args, dict):
|
|
2422
|
+
self._send_json_response(400, {"error": "Invalid tool arguments"})
|
|
2423
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Argumentos de herramienta inválidos")
|
|
2424
|
+
return
|
|
2425
|
+
tool_args_error = validate_local_tool_arguments(tool_name, tool_args)
|
|
2426
|
+
if tool_args_error:
|
|
2427
|
+
self._send_json_response(400, {"error": tool_args_error})
|
|
2428
|
+
self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, tool_args_error)
|
|
2429
|
+
return
|
|
2430
|
+
|
|
2431
|
+
req_path = self.path.split("?", 1)[0].rstrip("/")
|
|
1561
2432
|
|
|
1562
2433
|
# Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
|
|
1563
2434
|
tool_name = params.get("name", "") if isinstance(params, dict) else ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|