zerochat 7.8.0__tar.gz → 7.11.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.11.0}/PKG-INFO +1 -1
- {zerochat-7.8.0 → zerochat-7.11.0}/pyproject.toml +1 -1
- {zerochat-7.8.0 → zerochat-7.11.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-7.8.0 → zerochat-7.11.0}/zerochat.py +877 -91
- {zerochat-7.8.0 → zerochat-7.11.0}/LICENSE +0 -0
- {zerochat-7.8.0 → zerochat-7.11.0}/README.md +0 -0
- {zerochat-7.8.0 → zerochat-7.11.0}/setup.cfg +0 -0
- {zerochat-7.8.0 → zerochat-7.11.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-7.8.0 → zerochat-7.11.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-7.8.0 → zerochat-7.11.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-7.8.0 → zerochat-7.11.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.11.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."""
|
|
@@ -105,9 +108,10 @@ def format_uptime(seconds: float) -> str:
|
|
|
105
108
|
|
|
106
109
|
class ConsoleControl:
|
|
107
110
|
"""Atajos de consola y línea de estado, solo para terminales interactivos."""
|
|
108
|
-
def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser):
|
|
111
|
+
def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser, target_url: str | None = None):
|
|
109
112
|
self.server = server
|
|
110
113
|
self.parser = parser
|
|
114
|
+
self.target_url = target_url
|
|
111
115
|
self.started_at = time.monotonic()
|
|
112
116
|
self.last_activity = self.started_at
|
|
113
117
|
self.stop_event = threading.Event()
|
|
@@ -187,7 +191,7 @@ class ConsoleControl:
|
|
|
187
191
|
if self.status_visible:
|
|
188
192
|
sys.stdout.write("\r\033[2K")
|
|
189
193
|
self.status_visible = False
|
|
190
|
-
print("\nComandos de consola: [h] ayuda · [x] salir ordenadamente\n", flush=True)
|
|
194
|
+
print("\nComandos de consola: [h] ayuda · [n] Navegador · [x] salir ordenadamente\n", flush=True)
|
|
191
195
|
print(self.parser.format_help().rstrip(), flush=True)
|
|
192
196
|
self.last_activity = time.monotonic()
|
|
193
197
|
|
|
@@ -198,7 +202,7 @@ class ConsoleControl:
|
|
|
198
202
|
with self.lock:
|
|
199
203
|
if time.monotonic() - self.last_activity < CONSOLE_STATUS_IDLE_SECONDS:
|
|
200
204
|
return
|
|
201
|
-
sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [x] salir")
|
|
205
|
+
sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [n] Navegador · [x] salir")
|
|
202
206
|
sys.stdout.flush()
|
|
203
207
|
self.status_visible = True
|
|
204
208
|
|
|
@@ -209,6 +213,9 @@ class ConsoleControl:
|
|
|
209
213
|
def _handle_key(self, key: str):
|
|
210
214
|
if key.lower() == "h":
|
|
211
215
|
self.show_help()
|
|
216
|
+
elif key.lower() == "n":
|
|
217
|
+
if self.target_url:
|
|
218
|
+
launch_browser(self.target_url)
|
|
212
219
|
elif key.lower() == "x":
|
|
213
220
|
self.log(f"[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
|
|
214
221
|
stop_zerochat_server(self.server)
|
|
@@ -388,34 +395,54 @@ def check_version():
|
|
|
388
395
|
# Herramientas Locales Core
|
|
389
396
|
# ==============================================================================
|
|
390
397
|
|
|
391
|
-
def list_directory(path: str = ".") -> str:
|
|
392
|
-
"""
|
|
398
|
+
def list_directory(path: str = ".", recursive: bool = False) -> str:
|
|
399
|
+
"""List files and directories in a local directory with safe bounded recursive traversal."""
|
|
393
400
|
try:
|
|
394
401
|
target = Path(path).expanduser().resolve()
|
|
395
402
|
if not target.exists():
|
|
396
|
-
return json.dumps({"success": False, "error": f"
|
|
403
|
+
return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
|
|
397
404
|
if not target.is_dir():
|
|
398
|
-
return json.dumps({"success": False, "error": f"
|
|
405
|
+
return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)
|
|
399
406
|
|
|
407
|
+
max_depth = 3 if recursive else 1
|
|
400
408
|
entries = []
|
|
401
|
-
|
|
409
|
+
|
|
410
|
+
def _scan(current_path: Path, current_depth: int):
|
|
411
|
+
if current_depth > max_depth or len(entries) >= 1000:
|
|
412
|
+
return
|
|
402
413
|
try:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
414
|
+
with os.scandir(current_path) as it:
|
|
415
|
+
items = list(it)
|
|
416
|
+
items.sort(key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()))
|
|
417
|
+
for entry in items:
|
|
418
|
+
if len(entries) >= 1000:
|
|
419
|
+
break
|
|
420
|
+
try:
|
|
421
|
+
stat = entry.stat(follow_symlinks=False)
|
|
422
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
423
|
+
entry_data = {
|
|
424
|
+
"name": entry.name,
|
|
425
|
+
"path": str(Path(entry.path).resolve()),
|
|
426
|
+
"type": "directory" if is_dir else "file",
|
|
427
|
+
"size_bytes": None if is_dir else stat.st_size,
|
|
428
|
+
"is_symlink": entry.is_symlink()
|
|
429
|
+
}
|
|
430
|
+
if recursive:
|
|
431
|
+
entry_data["relative_path"] = str(Path(entry.path).resolve().relative_to(target))
|
|
432
|
+
entries.append(entry_data)
|
|
433
|
+
if recursive and is_dir and not entry.is_symlink():
|
|
434
|
+
_scan(Path(entry.path), current_depth + 1)
|
|
435
|
+
except (PermissionError, FileNotFoundError):
|
|
436
|
+
continue
|
|
412
437
|
except (PermissionError, FileNotFoundError):
|
|
413
|
-
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
_scan(target, 1)
|
|
414
441
|
|
|
415
|
-
entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
|
|
416
442
|
return json.dumps({
|
|
417
443
|
"success": True,
|
|
418
444
|
"path": str(target),
|
|
445
|
+
"recursive": bool(recursive),
|
|
419
446
|
"total_items": len(entries),
|
|
420
447
|
"entries": entries
|
|
421
448
|
}, ensure_ascii=False, indent=2)
|
|
@@ -423,34 +450,65 @@ def list_directory(path: str = ".") -> str:
|
|
|
423
450
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
424
451
|
|
|
425
452
|
|
|
426
|
-
def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: int = 100000) -> str:
|
|
427
|
-
"""
|
|
453
|
+
def read_file(path: str, start_line: int = 1, end_line: int = None, max_lines: int = 500, max_bytes: int = 100000) -> str:
|
|
454
|
+
"""Read text content from a local file in streaming mode with safe ranges and bounds."""
|
|
428
455
|
try:
|
|
429
456
|
target = Path(path).expanduser().resolve()
|
|
430
457
|
if not target.exists():
|
|
431
|
-
return json.dumps({"success": False, "error": f"
|
|
458
|
+
return json.dumps({"success": False, "error": f"File '{path}' does not exist."}, ensure_ascii=False)
|
|
432
459
|
if not target.is_file():
|
|
433
|
-
return json.dumps({"success": False, "error": f"
|
|
460
|
+
return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
|
|
434
461
|
|
|
435
462
|
file_size = target.stat().st_size
|
|
436
463
|
safe_max_bytes = max(1024, min(int(max_bytes), 2000000))
|
|
437
464
|
safe_start_line = max(1, int(start_line))
|
|
438
|
-
|
|
465
|
+
|
|
466
|
+
if end_line is not None:
|
|
467
|
+
safe_end_line = max(safe_start_line, int(end_line))
|
|
468
|
+
target_line_count = safe_end_line - safe_start_line + 1
|
|
469
|
+
else:
|
|
470
|
+
safe_end_line = None
|
|
471
|
+
target_line_count = max(1, min(int(max_lines), 2000))
|
|
472
|
+
|
|
473
|
+
selected_lines = []
|
|
474
|
+
current_bytes = 0
|
|
475
|
+
truncated_bytes = False
|
|
476
|
+
total_lines = 0
|
|
477
|
+
reached_end_bound = False
|
|
439
478
|
|
|
440
479
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
441
|
-
|
|
480
|
+
for line_no, line in enumerate(f, 1):
|
|
481
|
+
total_lines = line_no
|
|
482
|
+
if line_no < safe_start_line:
|
|
483
|
+
continue
|
|
484
|
+
if safe_end_line is not None and line_no > safe_end_line:
|
|
485
|
+
reached_end_bound = True
|
|
486
|
+
if line_no > safe_start_line + 50000:
|
|
487
|
+
break
|
|
488
|
+
continue
|
|
489
|
+
if safe_end_line is None and len(selected_lines) >= target_line_count:
|
|
490
|
+
reached_end_bound = True
|
|
491
|
+
if line_no > safe_start_line + 50000:
|
|
492
|
+
break
|
|
493
|
+
continue
|
|
442
494
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
495
|
+
if not reached_end_bound:
|
|
496
|
+
line_bytes = len(line.encode("utf-8"))
|
|
497
|
+
if current_bytes + line_bytes > safe_max_bytes:
|
|
498
|
+
remaining_budget = max(0, safe_max_bytes - current_bytes)
|
|
499
|
+
if remaining_budget > 0:
|
|
500
|
+
encoded = line.encode("utf-8")[:remaining_budget]
|
|
501
|
+
selected_lines.append(encoded.decode("utf-8", errors="ignore"))
|
|
502
|
+
truncated_bytes = True
|
|
503
|
+
reached_end_bound = True
|
|
504
|
+
else:
|
|
505
|
+
selected_lines.append(line)
|
|
506
|
+
current_bytes += line_bytes
|
|
446
507
|
|
|
447
|
-
selected_lines = lines[start_idx:end_idx] if start_idx < total_lines else []
|
|
448
508
|
content = "".join(selected_lines)
|
|
509
|
+
last_returned_line = safe_start_line + len(selected_lines) - 1 if selected_lines else safe_start_line - 1
|
|
449
510
|
|
|
450
|
-
|
|
451
|
-
if len(content.encode("utf-8")) > safe_max_bytes:
|
|
452
|
-
content = content[:safe_max_bytes]
|
|
453
|
-
truncated_bytes = True
|
|
511
|
+
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
512
|
|
|
455
513
|
return json.dumps({
|
|
456
514
|
"success": True,
|
|
@@ -458,44 +516,103 @@ def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: i
|
|
|
458
516
|
"size_bytes": file_size,
|
|
459
517
|
"total_lines": total_lines,
|
|
460
518
|
"start_line": safe_start_line,
|
|
519
|
+
"end_line": last_returned_line,
|
|
461
520
|
"lines_returned": len(selected_lines),
|
|
462
|
-
"truncated":
|
|
521
|
+
"truncated": is_truncated,
|
|
463
522
|
"content": content
|
|
464
523
|
}, ensure_ascii=False, indent=2)
|
|
465
524
|
except Exception as e:
|
|
466
525
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
467
526
|
|
|
468
527
|
|
|
469
|
-
def
|
|
470
|
-
"""
|
|
528
|
+
def write_file(path: str, content: str) -> str:
|
|
529
|
+
"""Create or overwrite a file completely and atomically."""
|
|
471
530
|
try:
|
|
472
531
|
target = Path(path).expanduser().resolve()
|
|
473
532
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
|
|
534
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
535
|
+
f.write(content)
|
|
536
|
+
temp_path.replace(target)
|
|
537
|
+
bytes_written = len(content.encode("utf-8"))
|
|
538
|
+
return json.dumps({
|
|
539
|
+
"success": True,
|
|
540
|
+
"path": str(target),
|
|
541
|
+
"bytes_written": bytes_written
|
|
542
|
+
}, ensure_ascii=False, indent=2)
|
|
543
|
+
except Exception as e:
|
|
544
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
474
545
|
|
|
546
|
+
|
|
547
|
+
def edit_file(path: str, old_str: str = None, new_str: str = None, content: str = None, mode: str = "surgical", target_content: str = None) -> str:
|
|
548
|
+
"""Modify an existing file surgically or atomically."""
|
|
549
|
+
try:
|
|
550
|
+
target = Path(path).expanduser().resolve()
|
|
551
|
+
|
|
552
|
+
# Preferred surgical mode (old_str -> new_str)
|
|
553
|
+
if old_str is not None and new_str is not None:
|
|
554
|
+
if not target.exists():
|
|
555
|
+
return json.dumps({"success": False, "error": f"File '{path}' does not exist. Use read_file to verify existing paths."}, ensure_ascii=False)
|
|
556
|
+
if not target.is_file():
|
|
557
|
+
return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)
|
|
558
|
+
|
|
559
|
+
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
560
|
+
file_text = f.read()
|
|
561
|
+
|
|
562
|
+
occurrences = file_text.count(old_str)
|
|
563
|
+
if occurrences == 0:
|
|
564
|
+
return json.dumps({
|
|
565
|
+
"success": False,
|
|
566
|
+
"error": f"Target text was not found in '{path}'. Check exact file content with read_file."
|
|
567
|
+
}, ensure_ascii=False)
|
|
568
|
+
if occurrences > 1:
|
|
569
|
+
return json.dumps({
|
|
570
|
+
"success": False,
|
|
571
|
+
"error": f"Found {occurrences} matches for the snippet in '{path}'. Provide more surrounding context in old_str to ensure a unique match."
|
|
572
|
+
}, ensure_ascii=False)
|
|
573
|
+
|
|
574
|
+
updated_text = file_text.replace(old_str, new_str, 1)
|
|
575
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
|
|
576
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
577
|
+
f.write(updated_text)
|
|
578
|
+
temp_path.replace(target)
|
|
579
|
+
return json.dumps({
|
|
580
|
+
"success": True,
|
|
581
|
+
"path": str(target),
|
|
582
|
+
"mode": "surgical",
|
|
583
|
+
"bytes_written": len(updated_text.encode("utf-8")),
|
|
584
|
+
"replacements": 1
|
|
585
|
+
}, ensure_ascii=False, indent=2)
|
|
586
|
+
|
|
587
|
+
# Legacy modes (write, append, replace_chunk)
|
|
588
|
+
if content is None:
|
|
589
|
+
return json.dumps({"success": False, "error": "Must provide old_str and new_str for surgical edit, or content for compatible modes."}, ensure_ascii=False)
|
|
590
|
+
|
|
591
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
475
592
|
if mode == "append":
|
|
476
593
|
with open(target, "a", encoding="utf-8") as f:
|
|
477
594
|
f.write(content)
|
|
478
595
|
bytes_written = len(content.encode("utf-8"))
|
|
479
596
|
elif mode == "replace_chunk":
|
|
480
597
|
if not target.exists():
|
|
481
|
-
return json.dumps({"success": False, "error": f"
|
|
598
|
+
return json.dumps({"success": False, "error": f"File '{path}' does not exist for replace_chunk."}, ensure_ascii=False)
|
|
482
599
|
if not target_content:
|
|
483
|
-
return json.dumps({"success": False, "error": "target_content
|
|
600
|
+
return json.dumps({"success": False, "error": "target_content is required in replace_chunk mode."}, ensure_ascii=False)
|
|
484
601
|
|
|
485
602
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
486
603
|
existing = f.read()
|
|
487
604
|
|
|
488
605
|
if target_content not in existing:
|
|
489
|
-
return json.dumps({"success": False, "error": "target_content
|
|
606
|
+
return json.dumps({"success": False, "error": "target_content was not found in the file."}, ensure_ascii=False)
|
|
490
607
|
|
|
491
608
|
new_text = existing.replace(target_content, content, 1)
|
|
492
|
-
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
|
|
609
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
|
|
493
610
|
with open(temp_path, "w", encoding="utf-8") as f:
|
|
494
611
|
f.write(new_text)
|
|
495
612
|
temp_path.replace(target)
|
|
496
613
|
bytes_written = len(new_text.encode("utf-8"))
|
|
497
614
|
else: # write
|
|
498
|
-
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
|
|
615
|
+
temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
|
|
499
616
|
with open(temp_path, "w", encoding="utf-8") as f:
|
|
500
617
|
f.write(content)
|
|
501
618
|
temp_path.replace(target)
|
|
@@ -511,8 +628,135 @@ def edit_file(path: str, content: str, mode: str = "write", target_content: str
|
|
|
511
628
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
512
629
|
|
|
513
630
|
|
|
631
|
+
def truncate_terminal_output(text: str, max_chars: int = 8000, head_lines: int = 50, tail_lines: int = 30) -> tuple[str, bool]:
|
|
632
|
+
"""Outputs >8,000 characters are truncated to first 50 lines + warning + last 30 lines."""
|
|
633
|
+
if len(text) <= max_chars:
|
|
634
|
+
return text, False
|
|
635
|
+
lines = text.splitlines(keepends=True)
|
|
636
|
+
if len(lines) <= head_lines + tail_lines:
|
|
637
|
+
half = max_chars // 2
|
|
638
|
+
return (
|
|
639
|
+
text[:half]
|
|
640
|
+
+ f"\n\n[... Output truncated due to length ({len(text)} total characters) ...]\n\n"
|
|
641
|
+
+ text[-half:],
|
|
642
|
+
True
|
|
643
|
+
)
|
|
644
|
+
omitted = len(lines) - head_lines - tail_lines
|
|
645
|
+
head = "".join(lines[:head_lines])
|
|
646
|
+
tail = "".join(lines[-tail_lines:])
|
|
647
|
+
warning = f"\n\n[... Output truncated: omitted {omitted} lines ({len(text)} total characters) ...]\n\n"
|
|
648
|
+
return head + warning + tail, True
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class PersistentBashSession:
|
|
652
|
+
"""Maintains working directory (cwd) and environment variables across successive calls."""
|
|
653
|
+
def __init__(self):
|
|
654
|
+
self._dir = tempfile.mkdtemp(prefix="zerochat_bash_")
|
|
655
|
+
self._cwd_file = Path(self._dir) / "cwd"
|
|
656
|
+
self._env_file = Path(self._dir) / "env.sh"
|
|
657
|
+
self._cwd = str(Path.cwd().resolve())
|
|
658
|
+
self._lock = threading.Lock()
|
|
659
|
+
atexit.register(self.cleanup)
|
|
660
|
+
|
|
661
|
+
def cleanup(self):
|
|
662
|
+
try:
|
|
663
|
+
shutil.rmtree(self._dir, ignore_errors=True)
|
|
664
|
+
except Exception:
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
def run(self, command: str, timeout_seconds: int = 30) -> str:
|
|
668
|
+
with self._lock:
|
|
669
|
+
safe_timeout = max(1, min(int(timeout_seconds), 300))
|
|
670
|
+
runner_script = Path(self._dir) / f"runner_{time.time_ns()}.sh"
|
|
671
|
+
|
|
672
|
+
script_content = (
|
|
673
|
+
"if [ -f " + shlex.quote(str(self._env_file)) + " ]; then\n"
|
|
674
|
+
" . " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true\n"
|
|
675
|
+
"fi\n"
|
|
676
|
+
"cd " + shlex.quote(self._cwd) + " 2>/dev/null || true\n"
|
|
677
|
+
"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"
|
|
678
|
+
+ command + "\n"
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
with open(runner_script, "w", encoding="utf-8") as f:
|
|
683
|
+
f.write(script_content)
|
|
684
|
+
runner_script.chmod(0o700)
|
|
685
|
+
|
|
686
|
+
proc = subprocess.Popen(
|
|
687
|
+
["bash", str(runner_script)],
|
|
688
|
+
cwd=self._cwd,
|
|
689
|
+
stdout=subprocess.PIPE,
|
|
690
|
+
stderr=subprocess.PIPE,
|
|
691
|
+
text=True,
|
|
692
|
+
encoding="utf-8",
|
|
693
|
+
errors="replace",
|
|
694
|
+
start_new_session=True if hasattr(os, "setsid") else False
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
stdout, stderr = proc.communicate(timeout=safe_timeout)
|
|
699
|
+
returncode = proc.returncode
|
|
700
|
+
except subprocess.TimeoutExpired:
|
|
701
|
+
try:
|
|
702
|
+
if hasattr(os, "killpg") and hasattr(os, "getpgid"):
|
|
703
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
704
|
+
else:
|
|
705
|
+
proc.kill()
|
|
706
|
+
except Exception:
|
|
707
|
+
try:
|
|
708
|
+
proc.kill()
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
proc.communicate()
|
|
712
|
+
return json.dumps({
|
|
713
|
+
"success": False,
|
|
714
|
+
"error": f"Command timed out after {timeout_seconds} seconds (terminated with SIGKILL).",
|
|
715
|
+
"cwd": self._cwd
|
|
716
|
+
}, ensure_ascii=False)
|
|
717
|
+
|
|
718
|
+
if self._cwd_file.is_file():
|
|
719
|
+
try:
|
|
720
|
+
saved_cwd = self._cwd_file.read_text(encoding="utf-8").strip()
|
|
721
|
+
if saved_cwd and Path(saved_cwd).is_dir():
|
|
722
|
+
self._cwd = saved_cwd
|
|
723
|
+
except Exception:
|
|
724
|
+
pass
|
|
725
|
+
|
|
726
|
+
truncated_out, was_out_trunc = truncate_terminal_output(stdout)
|
|
727
|
+
truncated_err, was_err_trunc = truncate_terminal_output(stderr)
|
|
728
|
+
|
|
729
|
+
return json.dumps({
|
|
730
|
+
"success": returncode == 0,
|
|
731
|
+
"returncode": returncode,
|
|
732
|
+
"stdout": truncated_out,
|
|
733
|
+
"stderr": truncated_err,
|
|
734
|
+
"cwd": self._cwd,
|
|
735
|
+
"truncated": was_out_trunc or was_err_trunc
|
|
736
|
+
}, ensure_ascii=False, indent=2)
|
|
737
|
+
|
|
738
|
+
except Exception as e:
|
|
739
|
+
return json.dumps({"success": False, "error": str(e), "cwd": self._cwd}, ensure_ascii=False)
|
|
740
|
+
finally:
|
|
741
|
+
try:
|
|
742
|
+
if runner_script.is_file():
|
|
743
|
+
runner_script.unlink()
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
BASH_SESSION = PersistentBashSession()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def bash(command: str, timeout_seconds: int = 30) -> str:
|
|
752
|
+
"""Execute a command in an interactive persistent bash shell session."""
|
|
753
|
+
return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
|
|
754
|
+
|
|
755
|
+
|
|
514
756
|
def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) -> str:
|
|
515
|
-
"""
|
|
757
|
+
"""Execute a command in the system shell and capture stdout and stderr."""
|
|
758
|
+
if cwd == "." or cwd == BASH_SESSION._cwd:
|
|
759
|
+
return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
|
|
516
760
|
try:
|
|
517
761
|
target_cwd = Path(cwd).expanduser().resolve()
|
|
518
762
|
if not target_cwd.exists() or not target_cwd.is_dir():
|
|
@@ -528,15 +772,417 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
|
|
|
528
772
|
encoding="utf-8",
|
|
529
773
|
errors="replace"
|
|
530
774
|
)
|
|
775
|
+
trunc_out, was_out_trunc = truncate_terminal_output(proc.stdout)
|
|
776
|
+
trunc_err, was_err_trunc = truncate_terminal_output(proc.stderr)
|
|
531
777
|
return json.dumps({
|
|
532
778
|
"success": proc.returncode == 0,
|
|
533
779
|
"returncode": proc.returncode,
|
|
534
|
-
"stdout":
|
|
535
|
-
"stderr":
|
|
536
|
-
"cwd": str(target_cwd)
|
|
780
|
+
"stdout": trunc_out,
|
|
781
|
+
"stderr": trunc_err,
|
|
782
|
+
"cwd": str(target_cwd),
|
|
783
|
+
"truncated": was_out_trunc or was_err_trunc
|
|
537
784
|
}, ensure_ascii=False, indent=2)
|
|
538
785
|
except subprocess.TimeoutExpired:
|
|
539
|
-
return json.dumps({"success": False, "error": f"
|
|
786
|
+
return json.dumps({"success": False, "error": f"Command timed out after {timeout_seconds} seconds."}, ensure_ascii=False)
|
|
787
|
+
except Exception as e:
|
|
788
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
|
|
792
|
+
"""Recursively search for plain text or regular expressions across project files."""
|
|
793
|
+
try:
|
|
794
|
+
target = Path(path).expanduser().resolve()
|
|
795
|
+
if not target.exists():
|
|
796
|
+
return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
|
|
797
|
+
if not target.is_dir():
|
|
798
|
+
return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)
|
|
799
|
+
|
|
800
|
+
try:
|
|
801
|
+
regex = re.compile(query, re.MULTILINE)
|
|
802
|
+
except re.error:
|
|
803
|
+
regex = re.compile(re.escape(query), re.MULTILINE)
|
|
804
|
+
|
|
805
|
+
ignored_dirs = {".git", ".venv", "node_modules", "__pycache__", ".pytest_cache", ".cache"}
|
|
806
|
+
matches = []
|
|
807
|
+
files_searched = 0
|
|
808
|
+
max_file_size = 2 * 1024 * 1024
|
|
809
|
+
truncated = False
|
|
810
|
+
|
|
811
|
+
for root, dirs, files in os.walk(target):
|
|
812
|
+
dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]
|
|
813
|
+
|
|
814
|
+
for fname in sorted(files):
|
|
815
|
+
if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
fpath = Path(root) / fname
|
|
819
|
+
try:
|
|
820
|
+
stat = fpath.stat()
|
|
821
|
+
if stat.st_size > max_file_size:
|
|
822
|
+
continue
|
|
823
|
+
except OSError:
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
files_searched += 1
|
|
827
|
+
try:
|
|
828
|
+
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
|
|
829
|
+
for line_idx, line in enumerate(f, 1):
|
|
830
|
+
if regex.search(line):
|
|
831
|
+
try:
|
|
832
|
+
rel_path = str(fpath.relative_to(target))
|
|
833
|
+
except ValueError:
|
|
834
|
+
rel_path = str(fpath)
|
|
835
|
+
matches.append({
|
|
836
|
+
"file": str(fpath),
|
|
837
|
+
"relative_path": rel_path,
|
|
838
|
+
"line_number": line_idx,
|
|
839
|
+
"content": line.rstrip("\r\n")[:300]
|
|
840
|
+
})
|
|
841
|
+
if len(matches) >= max_results:
|
|
842
|
+
truncated = True
|
|
843
|
+
break
|
|
844
|
+
except (PermissionError, OSError):
|
|
845
|
+
continue
|
|
846
|
+
|
|
847
|
+
if truncated:
|
|
848
|
+
break
|
|
849
|
+
if truncated:
|
|
850
|
+
break
|
|
851
|
+
|
|
852
|
+
return json.dumps({
|
|
853
|
+
"success": True,
|
|
854
|
+
"path": str(target),
|
|
855
|
+
"query": query,
|
|
856
|
+
"file_pattern": file_pattern,
|
|
857
|
+
"files_searched": files_searched,
|
|
858
|
+
"total_matches": len(matches),
|
|
859
|
+
"truncated": truncated,
|
|
860
|
+
"matches": matches
|
|
861
|
+
}, ensure_ascii=False, indent=2)
|
|
862
|
+
except Exception as e:
|
|
863
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def get_diagnostics(path: str | None = None) -> str:
|
|
867
|
+
"""Retrieve syntax diagnostics and code errors for a file or the entire workspace."""
|
|
868
|
+
try:
|
|
869
|
+
if path is not None and str(path).strip():
|
|
870
|
+
target = Path(str(path).strip()).expanduser().resolve()
|
|
871
|
+
else:
|
|
872
|
+
target = Path.cwd().resolve()
|
|
873
|
+
|
|
874
|
+
if not target.exists():
|
|
875
|
+
return json.dumps({
|
|
876
|
+
"success": False,
|
|
877
|
+
"error": f"Path '{path}' does not exist."
|
|
878
|
+
}, ensure_ascii=False)
|
|
879
|
+
|
|
880
|
+
diagnostics: list[dict] = []
|
|
881
|
+
files_checked = 0
|
|
882
|
+
|
|
883
|
+
def check_single_file(fpath: Path) -> list[dict]:
|
|
884
|
+
ext = fpath.suffix.lower()
|
|
885
|
+
file_diags: list[dict] = []
|
|
886
|
+
try:
|
|
887
|
+
rel_display = str(fpath.relative_to(Path.cwd()))
|
|
888
|
+
except ValueError:
|
|
889
|
+
rel_display = str(fpath)
|
|
890
|
+
|
|
891
|
+
if ext == ".py":
|
|
892
|
+
try:
|
|
893
|
+
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
894
|
+
compile(content, str(fpath), "exec")
|
|
895
|
+
except (SyntaxError, IndentationError) as e:
|
|
896
|
+
file_diags.append({
|
|
897
|
+
"file": rel_display,
|
|
898
|
+
"line": e.lineno or 1,
|
|
899
|
+
"column": e.offset or 1,
|
|
900
|
+
"severity": "error",
|
|
901
|
+
"message": f"SyntaxError: {e.msg}"
|
|
902
|
+
})
|
|
903
|
+
elif ext in (".js", ".mjs", ".cjs"):
|
|
904
|
+
node_bin = shutil.which("node")
|
|
905
|
+
if node_bin:
|
|
906
|
+
try:
|
|
907
|
+
res = subprocess.run([node_bin, "--check", str(fpath)], capture_output=True, text=True, timeout=10)
|
|
908
|
+
if res.returncode != 0:
|
|
909
|
+
stderr = res.stderr or ""
|
|
910
|
+
line_match = re.search(r':(\d+)\n([^\n]+)\n(\s*)\^', stderr)
|
|
911
|
+
line = int(line_match.group(1)) if line_match else 1
|
|
912
|
+
col = len(line_match.group(3)) + 1 if line_match else 1
|
|
913
|
+
msg_match = re.search(r'(SyntaxError:[^\n]+)', stderr)
|
|
914
|
+
msg = msg_match.group(1) if msg_match else (stderr.strip().splitlines()[-1] if stderr.strip() else "Syntax error")
|
|
915
|
+
file_diags.append({
|
|
916
|
+
"file": rel_display,
|
|
917
|
+
"line": line,
|
|
918
|
+
"column": col,
|
|
919
|
+
"severity": "error",
|
|
920
|
+
"message": msg
|
|
921
|
+
})
|
|
922
|
+
except Exception:
|
|
923
|
+
pass
|
|
924
|
+
elif ext == ".json":
|
|
925
|
+
try:
|
|
926
|
+
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
927
|
+
json.loads(content)
|
|
928
|
+
except json.JSONDecodeError as e:
|
|
929
|
+
file_diags.append({
|
|
930
|
+
"file": rel_display,
|
|
931
|
+
"line": e.lineno,
|
|
932
|
+
"column": e.colno,
|
|
933
|
+
"severity": "error",
|
|
934
|
+
"message": f"JSONDecodeError: {e.msg}"
|
|
935
|
+
})
|
|
936
|
+
return file_diags
|
|
937
|
+
|
|
938
|
+
if target.is_file():
|
|
939
|
+
files_checked = 1
|
|
940
|
+
diagnostics.extend(check_single_file(target))
|
|
941
|
+
else:
|
|
942
|
+
ignore_dirs = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".gemini", ".cache"}
|
|
943
|
+
max_scan = 200
|
|
944
|
+
for root, dirs, files in os.walk(target):
|
|
945
|
+
dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
|
|
946
|
+
for fname in sorted(files):
|
|
947
|
+
fp = Path(root) / fname
|
|
948
|
+
if fp.suffix.lower() in (".py", ".js", ".mjs", ".cjs", ".json"):
|
|
949
|
+
files_checked += 1
|
|
950
|
+
diagnostics.extend(check_single_file(fp))
|
|
951
|
+
if files_checked >= max_scan:
|
|
952
|
+
break
|
|
953
|
+
if files_checked >= max_scan:
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
error_count = sum(1 for d in diagnostics if d.get("severity") == "error")
|
|
957
|
+
warning_count = sum(1 for d in diagnostics if d.get("severity") == "warning")
|
|
958
|
+
msg = f"Found {len(diagnostics)} issue(s)." if diagnostics else "No diagnostic issues found."
|
|
959
|
+
|
|
960
|
+
return json.dumps({
|
|
961
|
+
"success": True,
|
|
962
|
+
"path": str(target),
|
|
963
|
+
"files_checked": files_checked,
|
|
964
|
+
"error_count": error_count,
|
|
965
|
+
"warning_count": warning_count,
|
|
966
|
+
"diagnostics": diagnostics,
|
|
967
|
+
"message": msg
|
|
968
|
+
}, ensure_ascii=False, indent=2)
|
|
969
|
+
except Exception as e:
|
|
970
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
class PersistentBrowserSession:
|
|
974
|
+
"""Manages a persistent headless browser session (Playwright) for browser_action."""
|
|
975
|
+
|
|
976
|
+
def __init__(self):
|
|
977
|
+
self._lock = threading.Lock()
|
|
978
|
+
self._process: subprocess.Popen | None = None
|
|
979
|
+
atexit.register(self.close)
|
|
980
|
+
|
|
981
|
+
def _ensure_running(self) -> subprocess.Popen:
|
|
982
|
+
if self._process is not None and self._process.poll() is None:
|
|
983
|
+
return self._process
|
|
984
|
+
|
|
985
|
+
node = shutil.which("node")
|
|
986
|
+
if not node:
|
|
987
|
+
raise RuntimeError("Node.js is not installed or not found in system PATH.")
|
|
988
|
+
|
|
989
|
+
runner_js = """
|
|
990
|
+
const readline = require('readline');
|
|
991
|
+
let playwright;
|
|
992
|
+
try {
|
|
993
|
+
playwright = require('playwright');
|
|
994
|
+
} catch (e1) {
|
|
995
|
+
try {
|
|
996
|
+
const path = require('path');
|
|
997
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
998
|
+
playwright = require(path.join(home, 'zerochat', 'services', 'playwright', 'node_modules', 'playwright'));
|
|
999
|
+
} catch (e2) {
|
|
1000
|
+
console.log(JSON.stringify({
|
|
1001
|
+
success: false,
|
|
1002
|
+
error: "Playwright is not available. Install it with 'npm install playwright' or enable the Playwright MCP service."
|
|
1003
|
+
}));
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
(async () => {
|
|
1009
|
+
let browser, context, page;
|
|
1010
|
+
try {
|
|
1011
|
+
const candidates = [
|
|
1012
|
+
{}, // 1. Playwright bundled Chromium
|
|
1013
|
+
{ channel: 'chrome' }, // 2. System Google Chrome
|
|
1014
|
+
{ channel: 'msedge' }, // 3. System Microsoft Edge (Windows 10/11)
|
|
1015
|
+
{ channel: 'chromium' } // 4. System Chromium (/usr/bin/chromium)
|
|
1016
|
+
];
|
|
1017
|
+
let lastErr = null;
|
|
1018
|
+
for (const cand of candidates) {
|
|
1019
|
+
try {
|
|
1020
|
+
browser = await playwright.chromium.launch({ ...cand, headless: true });
|
|
1021
|
+
if (browser) break;
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
lastErr = e;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (!browser) {
|
|
1027
|
+
throw lastErr || new Error('No Chromium-based browser found (Playwright, Chrome, Edge, or Chromium).');
|
|
1028
|
+
}
|
|
1029
|
+
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
1030
|
+
page = await context.newPage();
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
console.log(JSON.stringify({ success: false, error: 'Failed to start browser: ' + err.message }));
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
console.log(JSON.stringify({ ready: true }));
|
|
1037
|
+
|
|
1038
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
1039
|
+
for await (const line of rl) {
|
|
1040
|
+
if (!line.trim()) continue;
|
|
1041
|
+
let req;
|
|
1042
|
+
try {
|
|
1043
|
+
req = JSON.parse(line);
|
|
1044
|
+
} catch (e) {
|
|
1045
|
+
console.log(JSON.stringify({ success: false, error: 'Invalid command JSON' }));
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
try {
|
|
1050
|
+
const act = req.action;
|
|
1051
|
+
if (act === 'navigate') {
|
|
1052
|
+
if (!req.url) throw new Error("Parameter 'url' is required for action 'navigate'");
|
|
1053
|
+
await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1054
|
+
console.log(JSON.stringify({
|
|
1055
|
+
success: true,
|
|
1056
|
+
action: 'navigate',
|
|
1057
|
+
url: page.url(),
|
|
1058
|
+
title: await page.title()
|
|
1059
|
+
}));
|
|
1060
|
+
} else if (act === 'screenshot') {
|
|
1061
|
+
if (req.url && req.url !== page.url()) {
|
|
1062
|
+
await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1063
|
+
}
|
|
1064
|
+
const buf = await page.screenshot({ fullPage: false });
|
|
1065
|
+
console.log(JSON.stringify({
|
|
1066
|
+
success: true,
|
|
1067
|
+
action: 'screenshot',
|
|
1068
|
+
image_base64: buf.toString('base64'),
|
|
1069
|
+
mime_type: 'image/png',
|
|
1070
|
+
url: page.url(),
|
|
1071
|
+
title: await page.title()
|
|
1072
|
+
}));
|
|
1073
|
+
} else if (act === 'click') {
|
|
1074
|
+
if (!req.selector) throw new Error("Parameter 'selector' is required for action 'click'");
|
|
1075
|
+
await page.click(req.selector, { timeout: 15000 });
|
|
1076
|
+
console.log(JSON.stringify({
|
|
1077
|
+
success: true,
|
|
1078
|
+
action: 'click',
|
|
1079
|
+
selector: req.selector,
|
|
1080
|
+
url: page.url()
|
|
1081
|
+
}));
|
|
1082
|
+
} else if (act === 'fill') {
|
|
1083
|
+
if (!req.selector) throw new Error("Parameter 'selector' is required for action 'fill'");
|
|
1084
|
+
await page.fill(req.selector, req.value || '', { timeout: 15000 });
|
|
1085
|
+
console.log(JSON.stringify({
|
|
1086
|
+
success: true,
|
|
1087
|
+
action: 'fill',
|
|
1088
|
+
selector: req.selector,
|
|
1089
|
+
value: req.value || '',
|
|
1090
|
+
url: page.url()
|
|
1091
|
+
}));
|
|
1092
|
+
} else if (act === 'close') {
|
|
1093
|
+
await browser.close();
|
|
1094
|
+
console.log(JSON.stringify({ success: true, action: 'close' }));
|
|
1095
|
+
process.exit(0);
|
|
1096
|
+
} else {
|
|
1097
|
+
console.log(JSON.stringify({ success: false, error: 'Unsupported action: ' + act }));
|
|
1098
|
+
}
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
console.log(JSON.stringify({ success: false, error: err.message, action: req.action }));
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
})();
|
|
1104
|
+
"""
|
|
1105
|
+
proc = subprocess.Popen(
|
|
1106
|
+
[node, "-e", runner_js],
|
|
1107
|
+
stdin=subprocess.PIPE,
|
|
1108
|
+
stdout=subprocess.PIPE,
|
|
1109
|
+
stderr=subprocess.PIPE,
|
|
1110
|
+
text=True,
|
|
1111
|
+
encoding="utf-8",
|
|
1112
|
+
bufsize=1
|
|
1113
|
+
)
|
|
1114
|
+
self._process = proc
|
|
1115
|
+
first_line = proc.stdout.readline()
|
|
1116
|
+
if not first_line:
|
|
1117
|
+
err = proc.stderr.read()
|
|
1118
|
+
self._process = None
|
|
1119
|
+
raise RuntimeError(f"Failed to initialize headless browser: {err or 'process terminated unexpectedly'}")
|
|
1120
|
+
data = json.loads(first_line)
|
|
1121
|
+
if not data.get("ready"):
|
|
1122
|
+
self._process = None
|
|
1123
|
+
raise RuntimeError(data.get("error", "Unknown error starting browser"))
|
|
1124
|
+
|
|
1125
|
+
return proc
|
|
1126
|
+
|
|
1127
|
+
def execute(self, command: dict) -> dict:
|
|
1128
|
+
with self._lock:
|
|
1129
|
+
try:
|
|
1130
|
+
proc = self._ensure_running()
|
|
1131
|
+
except Exception as e:
|
|
1132
|
+
return {"success": False, "error": str(e)}
|
|
1133
|
+
|
|
1134
|
+
req_json = json.dumps(command, ensure_ascii=False) + "\n"
|
|
1135
|
+
try:
|
|
1136
|
+
proc.stdin.write(req_json)
|
|
1137
|
+
proc.stdin.flush()
|
|
1138
|
+
resp_line = proc.stdout.readline()
|
|
1139
|
+
if not resp_line:
|
|
1140
|
+
self.close()
|
|
1141
|
+
return {"success": False, "error": "Browser process closed unexpectedly."}
|
|
1142
|
+
return json.loads(resp_line)
|
|
1143
|
+
except Exception as e:
|
|
1144
|
+
self.close()
|
|
1145
|
+
return {"success": False, "error": f"Error executing browser action: {e}"}
|
|
1146
|
+
|
|
1147
|
+
def close(self):
|
|
1148
|
+
with self._lock:
|
|
1149
|
+
if self._process is not None:
|
|
1150
|
+
try:
|
|
1151
|
+
if self._process.poll() is None:
|
|
1152
|
+
try:
|
|
1153
|
+
self._process.stdin.write(json.dumps({"action": "close"}) + "\n")
|
|
1154
|
+
self._process.stdin.flush()
|
|
1155
|
+
self._process.wait(timeout=2)
|
|
1156
|
+
except Exception:
|
|
1157
|
+
self._process.kill()
|
|
1158
|
+
except Exception:
|
|
1159
|
+
pass
|
|
1160
|
+
self._process = None
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
_BROWSER_SESSION = PersistentBrowserSession()
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def browser_action(action: str, url: str | None = None, selector: str | None = None, value: str | None = None) -> str:
|
|
1167
|
+
"""Control a headless browser for UI testing and visual inspection."""
|
|
1168
|
+
try:
|
|
1169
|
+
act = (action or "").strip().lower()
|
|
1170
|
+
if act not in ("navigate", "screenshot", "click", "fill"):
|
|
1171
|
+
return json.dumps({
|
|
1172
|
+
"success": False,
|
|
1173
|
+
"error": f"Invalid browser action: '{action}'. Valid actions: navigate, screenshot, click, fill."
|
|
1174
|
+
}, ensure_ascii=False)
|
|
1175
|
+
|
|
1176
|
+
cmd = {"action": act}
|
|
1177
|
+
if url:
|
|
1178
|
+
cmd["url"] = str(url).strip()
|
|
1179
|
+
if selector:
|
|
1180
|
+
cmd["selector"] = str(selector).strip()
|
|
1181
|
+
if value is not None:
|
|
1182
|
+
cmd["value"] = str(value)
|
|
1183
|
+
|
|
1184
|
+
result = _BROWSER_SESSION.execute(cmd)
|
|
1185
|
+
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
540
1186
|
except Exception as e:
|
|
541
1187
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
542
1188
|
|
|
@@ -544,62 +1190,136 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
|
|
|
544
1190
|
LOCAL_TOOLS_DEFINITIONS = [
|
|
545
1191
|
{
|
|
546
1192
|
"name": "list_directory",
|
|
547
|
-
"description": "
|
|
1193
|
+
"description": "List files and directories in a local directory with safe bounded recursive traversal.",
|
|
548
1194
|
"inputSchema": {
|
|
549
1195
|
"type": "object",
|
|
550
1196
|
"properties": {
|
|
551
|
-
"path": {"type": "string", "description": "
|
|
1197
|
+
"path": {"type": "string", "description": "Relative or absolute directory path (defaults to '.')"},
|
|
1198
|
+
"recursive": {"type": "boolean", "description": "If true, traverses subdirectories up to depth 3", "default": False}
|
|
552
1199
|
}
|
|
553
1200
|
}
|
|
554
1201
|
},
|
|
555
1202
|
{
|
|
556
1203
|
"name": "read_file",
|
|
557
|
-
"description": "
|
|
1204
|
+
"description": "Read text content from a local file with safe ranges and bounds.",
|
|
558
1205
|
"inputSchema": {
|
|
559
1206
|
"type": "object",
|
|
560
1207
|
"properties": {
|
|
561
|
-
"path": {"type": "string", "description": "
|
|
562
|
-
"start_line": {"type": "integer", "description": "
|
|
563
|
-
"
|
|
564
|
-
"
|
|
1208
|
+
"path": {"type": "string", "description": "Path to the file to read"},
|
|
1209
|
+
"start_line": {"type": "integer", "description": "Starting line number (1-indexed)", "default": 1},
|
|
1210
|
+
"end_line": {"type": "integer", "description": "Ending line number (inclusive)"},
|
|
1211
|
+
"max_lines": {"type": "integer", "description": "Maximum number of lines to read when end_line is omitted", "default": 500},
|
|
1212
|
+
"max_bytes": {"type": "integer", "description": "Maximum byte budget", "default": 100000}
|
|
565
1213
|
},
|
|
566
1214
|
"required": ["path"]
|
|
567
1215
|
}
|
|
568
1216
|
},
|
|
569
1217
|
{
|
|
570
|
-
"name": "
|
|
571
|
-
"description": "
|
|
1218
|
+
"name": "write_file",
|
|
1219
|
+
"description": "Create or overwrite a file completely and atomically.",
|
|
572
1220
|
"inputSchema": {
|
|
573
1221
|
"type": "object",
|
|
574
1222
|
"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'"}
|
|
1223
|
+
"path": {"type": "string", "description": "Path to the file to write"},
|
|
1224
|
+
"content": {"type": "string", "description": "Full content of the file"}
|
|
579
1225
|
},
|
|
580
1226
|
"required": ["path", "content"]
|
|
581
1227
|
}
|
|
582
1228
|
},
|
|
1229
|
+
{
|
|
1230
|
+
"name": "edit_file",
|
|
1231
|
+
"description": "Edit an existing file surgically by replacing old_str with new_str (must match exactly once).",
|
|
1232
|
+
"inputSchema": {
|
|
1233
|
+
"type": "object",
|
|
1234
|
+
"properties": {
|
|
1235
|
+
"path": {"type": "string", "description": "Path to the file to edit"},
|
|
1236
|
+
"old_str": {"type": "string", "description": "Exact snippet to replace (must appear exactly once in the file)"},
|
|
1237
|
+
"new_str": {"type": "string", "description": "Replacement snippet"},
|
|
1238
|
+
"content": {"type": "string", "description": "Content to write in legacy/compatible mode"},
|
|
1239
|
+
"mode": {"type": "string", "enum": ["surgical", "write", "append", "replace_chunk"], "default": "surgical"},
|
|
1240
|
+
"target_content": {"type": "string", "description": "Exact target content in replace_chunk mode"}
|
|
1241
|
+
},
|
|
1242
|
+
"required": ["path"]
|
|
1243
|
+
}
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
"name": "bash",
|
|
1247
|
+
"description": "Execute a command in an interactive persistent shell session (preserves cwd and exported environment variables across calls).",
|
|
1248
|
+
"inputSchema": {
|
|
1249
|
+
"type": "object",
|
|
1250
|
+
"properties": {
|
|
1251
|
+
"command": {"type": "string", "description": "Shell command to execute (e.g. npm test, git status)"},
|
|
1252
|
+
"timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 30)", "default": 30}
|
|
1253
|
+
},
|
|
1254
|
+
"required": ["command"]
|
|
1255
|
+
}
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
"name": "search_files",
|
|
1259
|
+
"description": "Recursively search for plain text or regular expressions across project files.",
|
|
1260
|
+
"inputSchema": {
|
|
1261
|
+
"type": "object",
|
|
1262
|
+
"properties": {
|
|
1263
|
+
"query": {"type": "string", "description": "Plain text string or regular expression to search for"},
|
|
1264
|
+
"path": {"type": "string", "description": "Base search directory (defaults to '.')", "default": "."},
|
|
1265
|
+
"file_pattern": {"type": "string", "description": "Optional glob filter (e.g. *.js, *.py)"}
|
|
1266
|
+
},
|
|
1267
|
+
"required": ["query"]
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
583
1270
|
{
|
|
584
1271
|
"name": "execute_command",
|
|
585
|
-
"description": "
|
|
1272
|
+
"description": "Execute a shell command and capture stdout/stderr.",
|
|
586
1273
|
"inputSchema": {
|
|
587
1274
|
"type": "object",
|
|
588
1275
|
"properties": {
|
|
589
|
-
"command": {"type": "string", "description": "
|
|
590
|
-
"cwd": {"type": "string", "description": "
|
|
591
|
-
"timeout_seconds": {"type": "integer", "description": "
|
|
1276
|
+
"command": {"type": "string", "description": "Command to execute"},
|
|
1277
|
+
"cwd": {"type": "string", "description": "Working directory (defaults to '.')"},
|
|
1278
|
+
"timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 60)", "default": 60}
|
|
592
1279
|
},
|
|
593
1280
|
"required": ["command"]
|
|
594
1281
|
}
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
"name": "get_diagnostics",
|
|
1285
|
+
"description": "Retrieve syntax diagnostics and code errors for a file or the entire workspace.",
|
|
1286
|
+
"inputSchema": {
|
|
1287
|
+
"type": "object",
|
|
1288
|
+
"properties": {
|
|
1289
|
+
"path": {"type": "string", "description": "Path to the file or directory to inspect (defaults to '.')"}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
"name": "browser_action",
|
|
1295
|
+
"description": "Control a headless browser (Playwright) for web navigation, UI testing, and visual inspection via screenshots.",
|
|
1296
|
+
"inputSchema": {
|
|
1297
|
+
"type": "object",
|
|
1298
|
+
"properties": {
|
|
1299
|
+
"action": {
|
|
1300
|
+
"type": "string",
|
|
1301
|
+
"enum": ["navigate", "screenshot", "click", "fill"],
|
|
1302
|
+
"description": "Action to perform in the browser"
|
|
1303
|
+
},
|
|
1304
|
+
"url": {"type": "string", "description": "Target URL for navigate or screenshot"},
|
|
1305
|
+
"selector": {"type": "string", "description": "CSS selector for click or fill actions"},
|
|
1306
|
+
"value": {"type": "string", "description": "Text value to type for fill action"}
|
|
1307
|
+
},
|
|
1308
|
+
"required": ["action"]
|
|
1309
|
+
}
|
|
595
1310
|
}
|
|
596
1311
|
]
|
|
597
1312
|
|
|
598
1313
|
LOCAL_TOOL_HANDLERS = {
|
|
599
1314
|
"list_directory": list_directory,
|
|
600
1315
|
"read_file": read_file,
|
|
1316
|
+
"write_file": write_file,
|
|
601
1317
|
"edit_file": edit_file,
|
|
602
|
-
"
|
|
1318
|
+
"bash": bash,
|
|
1319
|
+
"search_files": search_files,
|
|
1320
|
+
"execute_command": execute_command,
|
|
1321
|
+
"get_diagnostics": get_diagnostics,
|
|
1322
|
+
"browser_action": browser_action
|
|
603
1323
|
}
|
|
604
1324
|
|
|
605
1325
|
# ==============================================================================
|
|
@@ -616,18 +1336,51 @@ MAX_COMMAND_LENGTH = 16384
|
|
|
616
1336
|
def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
|
|
617
1337
|
"""Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
|
|
618
1338
|
schemas = {
|
|
619
|
-
"list_directory": {
|
|
1339
|
+
"list_directory": {
|
|
1340
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1341
|
+
"recursive": (bool, None)
|
|
1342
|
+
},
|
|
620
1343
|
"read_file": {
|
|
621
|
-
"path": (str, MAX_PATH_LENGTH),
|
|
622
|
-
"
|
|
1344
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1345
|
+
"start_line": (int, None),
|
|
1346
|
+
"end_line": (int, None),
|
|
1347
|
+
"max_lines": (int, None),
|
|
1348
|
+
"max_bytes": (int, None)
|
|
1349
|
+
},
|
|
1350
|
+
"write_file": {
|
|
1351
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1352
|
+
"content": (str, MAX_HTTP_BODY_BYTES)
|
|
623
1353
|
},
|
|
624
1354
|
"edit_file": {
|
|
625
|
-
"path": (str, MAX_PATH_LENGTH),
|
|
626
|
-
"
|
|
1355
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1356
|
+
"old_str": (str, MAX_HTTP_BODY_BYTES),
|
|
1357
|
+
"new_str": (str, MAX_HTTP_BODY_BYTES),
|
|
1358
|
+
"content": (str, MAX_HTTP_BODY_BYTES),
|
|
1359
|
+
"mode": (str, 32),
|
|
1360
|
+
"target_content": (str, MAX_HTTP_BODY_BYTES)
|
|
1361
|
+
},
|
|
1362
|
+
"bash": {
|
|
1363
|
+
"command": (str, MAX_COMMAND_LENGTH),
|
|
1364
|
+
"timeout_seconds": (int, None)
|
|
1365
|
+
},
|
|
1366
|
+
"search_files": {
|
|
1367
|
+
"query": (str, 4096),
|
|
1368
|
+
"path": (str, MAX_PATH_LENGTH),
|
|
1369
|
+
"file_pattern": (str, 256)
|
|
627
1370
|
},
|
|
628
1371
|
"execute_command": {
|
|
629
|
-
"command": (str, MAX_COMMAND_LENGTH),
|
|
1372
|
+
"command": (str, MAX_COMMAND_LENGTH),
|
|
1373
|
+
"cwd": (str, MAX_PATH_LENGTH),
|
|
630
1374
|
"timeout_seconds": (int, None)
|
|
1375
|
+
},
|
|
1376
|
+
"get_diagnostics": {
|
|
1377
|
+
"path": (str, MAX_PATH_LENGTH)
|
|
1378
|
+
},
|
|
1379
|
+
"browser_action": {
|
|
1380
|
+
"action": (str, 32),
|
|
1381
|
+
"url": (str, 4096),
|
|
1382
|
+
"selector": (str, 1024),
|
|
1383
|
+
"value": (str, MAX_HTTP_BODY_BYTES)
|
|
631
1384
|
}
|
|
632
1385
|
}
|
|
633
1386
|
schema = schemas.get(tool_name)
|
|
@@ -639,19 +1392,38 @@ def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None
|
|
|
639
1392
|
if expected is None:
|
|
640
1393
|
return f"Argumento no permitido: {name}"
|
|
641
1394
|
expected_type, max_length = expected
|
|
642
|
-
if
|
|
643
|
-
|
|
1395
|
+
if expected_type is bool:
|
|
1396
|
+
if not isinstance(value, bool):
|
|
1397
|
+
return f"Tipo inválido para '{name}'"
|
|
1398
|
+
else:
|
|
1399
|
+
if isinstance(value, bool) or not isinstance(value, expected_type):
|
|
1400
|
+
return f"Tipo inválido para '{name}'"
|
|
644
1401
|
if max_length is not None and len(value) > max_length:
|
|
645
1402
|
return f"'{name}' excede el tamaño máximo permitido"
|
|
646
1403
|
|
|
647
1404
|
required = {
|
|
648
1405
|
"read_file": ("path",),
|
|
649
|
-
"
|
|
650
|
-
"
|
|
1406
|
+
"write_file": ("path", "content"),
|
|
1407
|
+
"edit_file": ("path",),
|
|
1408
|
+
"bash": ("command",),
|
|
1409
|
+
"search_files": ("query",),
|
|
1410
|
+
"execute_command": ("command",),
|
|
1411
|
+
"browser_action": ("action",)
|
|
651
1412
|
}
|
|
652
1413
|
for name in required.get(tool_name, ()):
|
|
653
1414
|
if name not in arguments:
|
|
654
1415
|
return f"Falta el argumento obligatorio: {name}"
|
|
1416
|
+
|
|
1417
|
+
if tool_name == "edit_file":
|
|
1418
|
+
has_surgical = "old_str" in arguments and "new_str" in arguments
|
|
1419
|
+
has_legacy = "content" in arguments
|
|
1420
|
+
if not has_surgical and not has_legacy:
|
|
1421
|
+
return "Faltan argumentos obligatorios: especifica 'old_str' y 'new_str' o 'content'"
|
|
1422
|
+
|
|
1423
|
+
if tool_name == "browser_action":
|
|
1424
|
+
if arguments.get("action") not in ("navigate", "screenshot", "click", "fill"):
|
|
1425
|
+
return f"Acción de navegador no permitida: {arguments.get('action')}"
|
|
1426
|
+
|
|
655
1427
|
return None
|
|
656
1428
|
|
|
657
1429
|
def sanitize_log_path(raw_path: str) -> str:
|
|
@@ -1590,6 +2362,12 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
1590
2362
|
return
|
|
1591
2363
|
if content_len > MAX_HTTP_BODY_BYTES:
|
|
1592
2364
|
self._log_req("POST", safe_path)
|
|
2365
|
+
if content_len <= MAX_HTTP_BODY_BYTES * 2:
|
|
2366
|
+
try:
|
|
2367
|
+
self.rfile.read(content_len)
|
|
2368
|
+
except Exception:
|
|
2369
|
+
pass
|
|
2370
|
+
self.close_connection = True
|
|
1593
2371
|
self._send_json_response(413, {"error": "Request body too large"})
|
|
1594
2372
|
self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
|
|
1595
2373
|
return
|
|
@@ -1946,6 +2724,28 @@ def open_browser(url: str) -> bool:
|
|
|
1946
2724
|
)
|
|
1947
2725
|
|
|
1948
2726
|
|
|
2727
|
+
def launch_browser(url: str) -> bool:
|
|
2728
|
+
"""Abre el navegador en la URL de la sesión y muestra información o diagnóstico en consola."""
|
|
2729
|
+
termux_detected = is_termux_environment()
|
|
2730
|
+
if termux_detected:
|
|
2731
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
|
|
2732
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
|
|
2733
|
+
try:
|
|
2734
|
+
if not open_browser(url):
|
|
2735
|
+
raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
|
|
2736
|
+
return True
|
|
2737
|
+
except Exception as e:
|
|
2738
|
+
console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
|
|
2739
|
+
console_log(" Traza de diagnóstico:", flush=True)
|
|
2740
|
+
traceback.print_exc()
|
|
2741
|
+
manual_command = get_manual_browser_command(url)
|
|
2742
|
+
if manual_command:
|
|
2743
|
+
console_log(" Termux detectado. Prueba este comando exacto:", flush=True)
|
|
2744
|
+
console_log(f" {manual_command}", flush=True)
|
|
2745
|
+
return False
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
|
|
1949
2749
|
# ==============================================================================
|
|
1950
2750
|
# Punto de Entrada Principal (CLI)
|
|
1951
2751
|
# ==============================================================================
|
|
@@ -2026,25 +2826,11 @@ def main():
|
|
|
2026
2826
|
print(f" Auto-cierre : Desactivado")
|
|
2027
2827
|
print("=" * 64, flush=True)
|
|
2028
2828
|
|
|
2029
|
-
CONSOLE_CONTROL = ConsoleControl(server, parser)
|
|
2829
|
+
CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
|
|
2030
2830
|
CONSOLE_CONTROL.start()
|
|
2031
2831
|
|
|
2032
2832
|
if not args.no_browser:
|
|
2033
|
-
|
|
2034
|
-
if termux_detected:
|
|
2035
|
-
console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
|
|
2036
|
-
console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
|
|
2037
|
-
try:
|
|
2038
|
-
if not open_browser(target_url):
|
|
2039
|
-
raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
|
|
2040
|
-
except Exception as e:
|
|
2041
|
-
console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
|
|
2042
|
-
console_log(" Traza de diagnóstico:", flush=True)
|
|
2043
|
-
traceback.print_exc()
|
|
2044
|
-
manual_command = get_manual_browser_command(target_url)
|
|
2045
|
-
if manual_command:
|
|
2046
|
-
console_log(" Termux detectado. Prueba este comando exacto:", flush=True)
|
|
2047
|
-
console_log(f" {manual_command}", flush=True)
|
|
2833
|
+
launch_browser(target_url)
|
|
2048
2834
|
|
|
2049
2835
|
if exit_on_close:
|
|
2050
2836
|
require_initial = not args.no_browser
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|