Files
AstrBot/astrbot/core/computer/booters/local.py
LIghtJUNction dcaaf6286a test: comprehensive test coverage and type fixes
- Add 100+ new test files covering provider sources, platform adapters,
  agent runners, star/plugin system, knowledge base, core utils,
  pipeline, computer tools, builtin commands, and dashboard routes
- Fix Python type errors in sqlite.py (col() wrappers for SQLModel),
  astr_agent_tool_exec, core_lifecycle, star context/manager
- Fix TypeScript strict mode errors across 30+ dashboard Vue files
- Add import smoke tests, auth roundtrip, startup tests
- Add compile-all check and CLI entry test for AUR compatibility
- Restore BotMessageAccumulator and helpers lost in merge
2026-04-29 06:29:56 +08:00

360 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import locale
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Any
from astrbot.api import logger
from astrbot.core.computer.olayer import (
FileSystemComponent,
PythonComponent,
ShellComponent,
)
from astrbot.core.computer.shell_session import PersistentShellSession
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_root,
get_astrbot_temp_path,
get_astrbot_workspaces_path,
)
from .base import ComputerBooter
_BLOCKED_COMMAND_PATTERNS = [
" rm -rf ",
" rm -fr ",
" rm -r ",
" mkfs",
" dd if=",
" shutdown",
" reboot",
" poweroff",
" halt",
" sudo ",
":(){:|:&};:",
" kill -9 ",
" killall ",
]
def _is_safe_command(command: str) -> bool:
cmd = f" {command.strip().lower()} "
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
def _ensure_safe_path(path: str) -> str:
abs_path = os.path.abspath(path)
allowed_roots = [
os.path.abspath(get_astrbot_root()),
os.path.abspath(get_astrbot_data_path()),
os.path.abspath(get_astrbot_temp_path()),
os.path.abspath(get_astrbot_workspaces_path()),
]
if not any(abs_path.startswith(root) for root in allowed_roots):
raise PermissionError("Path is outside the allowed computer roots.")
return abs_path
def _decode_bytes_with_fallback(
output: bytes | None,
*,
preferred_encoding: str | None = None,
) -> str:
if output is None:
return ""
preferred = locale.getpreferredencoding(False) or "utf-8"
attempted_encodings: list[str] = []
def _try_decode(encoding: str) -> str | None:
normalized = encoding.lower()
if normalized in attempted_encodings:
return None
attempted_encodings.append(normalized)
try:
return output.decode(encoding)
except (LookupError, UnicodeDecodeError):
return None
for encoding in filter(None, [preferred_encoding, "utf-8", "utf-8-sig"]):
if decoded := _try_decode(encoding):
return decoded
if os.name == "nt":
for encoding in ("mbcs", "cp936", "gbk", "gb18030", preferred):
if decoded := _try_decode(encoding):
return decoded
elif decoded := _try_decode(preferred):
return decoded
return output.decode("utf-8", errors="replace")
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
session_id: str | None = None,
) -> dict[str, Any]:
if not _is_safe_command(command):
raise PermissionError("Blocked unsafe shell command.")
key = session_id or "default"
session = PersistentShellSession.get_or_create(key)
return await session.exec(
command,
cwd=cwd,
env=env,
timeout=timeout,
background=background,
)
@staticmethod
async def shutdown_all() -> None:
await PersistentShellSession.cleanup_all()
@dataclass
class LocalPythonComponent(PythonComponent):
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
try:
result = subprocess.run(
[os.environ.get("PYTHON", sys.executable), "-c", code],
check=False,
timeout=timeout,
capture_output=True,
text=True,
)
stdout = "" if silent else result.stdout
stderr = result.stderr if result.returncode != 0 else ""
return {
"data": {
"output": {"text": stdout, "images": []},
"error": stderr,
},
}
except subprocess.TimeoutExpired:
return {
"data": {
"output": {"text": "", "images": []},
"error": "Execution timed out.",
},
}
return await asyncio.to_thread(_run)
@dataclass
class LocalFileSystemComponent(FileSystemComponent):
async def create_file(
self,
path: str,
content: str = "",
mode: int = 0o644,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(abs_path, mode)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
with open(abs_path, "rb") as f:
raw_content = f.read()
content = _decode_bytes_with_fallback(
raw_content,
preferred_encoding=encoding,
)
if offset is not None:
lines = content.splitlines(keepends=True)
start = offset
if limit is not None:
lines = lines[start : start + limit]
else:
lines = lines[start:]
content = "".join(lines)
elif limit is not None:
lines = content.splitlines(keepends=True)[:limit]
content = "".join(lines)
return {"success": True, "content": content}
return await asyncio.to_thread(_run)
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
"""Search file contents using grep-like pattern matching."""
def _run() -> dict[str, Any]:
search_path = _ensure_safe_path(path) if path else "."
cmd = ["grep", "-rn", pattern, search_path]
if after_context is not None:
cmd.extend(["-A", str(after_context)])
if before_context is not None:
cmd.extend(["-B", str(before_context)])
if glob:
cmd.extend(["--include", glob])
try:
result = subprocess.run(
cmd,
check=False,
capture_output=True,
text=True,
timeout=30,
)
return {
"success": True,
"content": result.stdout,
"error": result.stderr if result.returncode != 0 else "",
}
except subprocess.TimeoutExpired:
return {
"success": False,
"output": "",
"error": "Search timed out.",
}
return await asyncio.to_thread(_run)
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
with open(abs_path, encoding=encoding) as f:
content = f.read()
if replace_all:
new_content = content.replace(old_string, new_string)
else:
new_content = content.replace(old_string, new_string, 1)
if new_content == content:
return {
"success": False,
"error": f"String '{old_string}' not found in file.",
}
with open(abs_path, "w", encoding=encoding) as f:
f.write(new_content)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def write_file(
self,
path: str,
content: str,
mode: str = "w",
encoding: str = "utf-8",
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, mode, encoding=encoding) as f:
f.write(content)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def delete_file(self, path: str) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
if os.path.isdir(abs_path):
shutil.rmtree(abs_path)
else:
os.remove(abs_path)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def list_dir(
self,
path: str = ".",
show_hidden: bool = False,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
entries = os.listdir(abs_path)
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]
return {"success": True, "entries": entries}
return await asyncio.to_thread(_run)
class LocalBooter(ComputerBooter):
def __init__(self) -> None:
self._fs = LocalFileSystemComponent()
self._python = LocalPythonComponent()
self._shell = LocalShellComponent()
async def boot(self, session_id: str) -> None:
logger.info(f"Local computer booter initialized for session: {session_id}")
async def shutdown(self) -> None:
await LocalShellComponent.shutdown_all()
logger.info("Local computer booter shutdown complete.")
@property
def fs(self) -> FileSystemComponent:
return self._fs
@property
def python(self) -> PythonComponent:
return self._python
@property
def shell(self) -> ShellComponent:
return self._shell
async def upload_file(self, path: str, file_name: str) -> dict:
raise NotImplementedError(
"LocalBooter does not support upload_file operation. Use shell instead.",
)
async def download_file(self, remote_path: str, local_path: str) -> None:
raise NotImplementedError(
"LocalBooter does not support download_file operation. Use shell instead.",
)
async def available(self) -> bool:
return True