mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 17:47:06 +08:00
# Conflicts: # AGENTS.md # astrbot/__init__.py # astrbot/cli/utils/__init__.py # astrbot/core/astr_agent_tool_exec.py # astrbot/core/computer/booters/local.py # astrbot/core/cron/manager.py # astrbot/core/knowledge_base/kb_mgr.py # astrbot/core/knowledge_base/retrieval/manager.py # astrbot/core/platform/sources/discord/discord_platform_adapter.py # astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py # astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py # astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py # astrbot/core/platform/sources/wecom/wecom_adapter.py # astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py # astrbot/core/provider/func_tool_manager.py # astrbot/core/provider/sources/mimo_api_common.py # astrbot/core/star/star_manager.py # astrbot/core/tools/computer_tools/fs.py # astrbot/core/tools/computer_tools/python.py # astrbot/core/tools/computer_tools/shell.py # astrbot/core/tools/message_tools.py # astrbot/core/tools/web_search_tools.py # astrbot/core/utils/io.py # astrbot/core/utils/tencent_record_helper.py # dashboard/package.json # dashboard/src/assets/mdi-subset/materialdesignicons-subset.css # dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff # dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 # dashboard/src/components/ConfirmDialog.vue # dashboard/src/components/chat/Chat.vue # dashboard/src/components/chat/ChatInput.vue # dashboard/src/components/chat/ChatMessageList.vue # dashboard/src/components/chat/ConfigSelector.vue # dashboard/src/components/chat/MessageList.vue # dashboard/src/components/chat/ProjectDialog.vue # dashboard/src/components/chat/ProjectList.vue # dashboard/src/components/chat/ProjectView.vue # dashboard/src/components/chat/ProviderModelMenu.vue # dashboard/src/components/chat/RegenerateMenu.vue # dashboard/src/components/chat/StandaloneChat.vue # dashboard/src/components/chat/ThreadPanel.vue # dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue # dashboard/src/components/chat/message_list_comps/MarkdownMessagePart.vue # dashboard/src/components/chat/message_list_comps/ReasoningTimeline.vue # dashboard/src/components/chat/message_list_comps/RefsSidebar.vue # dashboard/src/components/config/UnsavedChangesConfirmDialog.vue # dashboard/src/components/extension/MarketPluginCard.vue # dashboard/src/components/extension/McpServersSection.vue # dashboard/src/components/extension/SkillsSection.vue # dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue # dashboard/src/components/extension/componentPanel/components/RenameDialog.vue # dashboard/src/components/folder/BaseCreateFolderDialog.vue # dashboard/src/components/folder/BaseFolderItemSelector.vue # dashboard/src/components/folder/BaseMoveToFolderDialog.vue # dashboard/src/components/platform/AddNewPlatform.vue # dashboard/src/components/provider/AddNewProvider.vue # dashboard/src/components/provider/ProviderModelsPanel.vue # dashboard/src/components/shared/BackupDialog.vue # dashboard/src/components/shared/ChangelogDialog.vue # dashboard/src/components/shared/ExtensionCard.vue # dashboard/src/components/shared/FileConfigItem.vue # dashboard/src/components/shared/KnowledgeBaseSelector.vue # dashboard/src/components/shared/ListConfigItem.vue # dashboard/src/components/shared/ObjectEditor.vue # dashboard/src/components/shared/PersonaForm.vue # dashboard/src/components/shared/PluginSetSelector.vue # dashboard/src/components/shared/ProviderSelector.vue # dashboard/src/components/shared/ReadmeDialog.vue # dashboard/src/components/shared/SidebarCustomizer.vue # dashboard/src/components/shared/T2ITemplateEditor.vue # dashboard/src/components/shared/UninstallConfirmDialog.vue # dashboard/src/components/shared/WaitingForRestart.vue # dashboard/src/composables/useMessages.ts # dashboard/src/composables/useProjects.ts # dashboard/src/composables/useProviderModelConfigDialog.ts # dashboard/src/composables/useProviderSources.ts # dashboard/src/i18n/locales/ru-RU/features/config-metadata.json # dashboard/src/i18n/locales/ru-RU/features/extension.json # dashboard/src/i18n/locales/ru-RU/features/provider.json # dashboard/src/layouts/full/vertical-header/VerticalHeader.vue # dashboard/src/scss/_override.scss # dashboard/src/stores/common.js # dashboard/src/stores/customizer.ts # dashboard/src/views/ConfigPage.vue # dashboard/src/views/ConversationPage.vue # dashboard/src/views/PlatformPage.vue # dashboard/src/views/SessionManagementPage.vue # dashboard/src/views/WelcomePage.vue # dashboard/src/views/alkaid/KnowledgeBase.vue # dashboard/src/views/alkaid/LongTermMemory.vue # dashboard/src/views/authentication/auth/LoginPage.vue # dashboard/src/views/extension/InstalledPluginsTab.vue # dashboard/src/views/extension/useExtensionPage.js # dashboard/src/views/knowledge-base/DocumentDetail.vue # dashboard/src/views/knowledge-base/KBList.vue # dashboard/src/views/knowledge-base/components/DocumentsTab.vue # dashboard/src/views/knowledge-base/components/SettingsTab.vue # dashboard/src/views/knowledge-base/components/TavilyKeyDialog.vue # dashboard/src/views/persona/FolderTree.vue # dashboard/src/views/persona/MoveToFolderDialog.vue # dashboard/src/views/persona/PersonaManager.vue # requirements.txt # tests/test_local_shell_component.py # tests/unit/test_func_tool_manager.py # tests/unit/test_web_search_tools.py
409 lines
13 KiB
Python
409 lines
13 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
|
|
from .bwrap import _decode_shell_output
|
|
|
|
_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")
|
|
|
|
|
|
async def _terminate_shell_process_tree(session: PersistentShellSession) -> None:
|
|
"""Terminate the persistent shell and its children after a timeout."""
|
|
proc = session._proc
|
|
if proc is None or proc.returncode is not None:
|
|
return
|
|
|
|
taskkill_succeeded = False
|
|
if sys.platform == "win32":
|
|
try:
|
|
taskkill_result = await asyncio.to_thread(
|
|
subprocess.run,
|
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
)
|
|
taskkill_succeeded = taskkill_result.returncode == 0
|
|
except (OSError, subprocess.SubprocessError):
|
|
taskkill_succeeded = False
|
|
|
|
if not taskkill_succeeded:
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
except (TimeoutError, ProcessLookupError):
|
|
pass
|
|
|
|
|
|
@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)
|
|
execution = session.exec(
|
|
command,
|
|
cwd=cwd,
|
|
env=env,
|
|
timeout=None if timeout is not None and not background else timeout,
|
|
background=background,
|
|
)
|
|
if background or timeout is None:
|
|
return await execution
|
|
|
|
try:
|
|
return await asyncio.wait_for(execution, timeout=timeout)
|
|
except TimeoutError:
|
|
await _terminate_shell_process_tree(session)
|
|
await PersistentShellSession.cleanup(key)
|
|
raise
|
|
|
|
@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,
|
|
cwd: str | None = None,
|
|
) -> dict[str, Any]:
|
|
def _run() -> dict[str, Any]:
|
|
try:
|
|
working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root()
|
|
result = subprocess.run(
|
|
[os.environ.get("PYTHON", sys.executable), "-c", code],
|
|
check=False,
|
|
timeout=timeout,
|
|
capture_output=True,
|
|
cwd=working_dir,
|
|
)
|
|
stdout = "" if silent else _decode_shell_output(result.stdout)
|
|
stderr = (
|
|
_decode_shell_output(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, **kwargs) -> 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
|