diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 2d330eab1c..fa10a9e98b 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -26,7 +26,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 - name: Setup Node.js diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml index 1f53e8b53e..ca98d348fa 100644 --- a/.github/workflows/code-format.yml +++ b/.github/workflows/code-format.yml @@ -46,7 +46,7 @@ jobs: node-version: '26.5.0' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 - name: Install root toolchain diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b374fe5b50..ca6d7d55ed 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -66,7 +66,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,6 +95,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index b8ad5dd8d5..c60141845f 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 diff --git a/.github/workflows/linux-development.yml b/.github/workflows/linux-development.yml index 3487cf8eb4..e22c4d8ad3 100644 --- a/.github/workflows/linux-development.yml +++ b/.github/workflows/linux-development.yml @@ -32,7 +32,7 @@ jobs: package-manager-cache: false - name: Set up pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 diff --git a/.github/workflows/plugin-ui-e2e.yml b/.github/workflows/plugin-ui-e2e.yml index d2c3e91ece..fe558b5ddc 100644 --- a/.github/workflows/plugin-ui-e2e.yml +++ b/.github/workflows/plugin-ui-e2e.yml @@ -33,7 +33,7 @@ jobs: python -m pip install uv bash scripts/uv_sync_ci.sh --group dev --locked - name: Set up pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 - name: Set up Node.js @@ -72,7 +72,7 @@ jobs: python -m pip install uv bash scripts/uv_sync_ci.sh --group dev --locked - name: Set up pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 - name: Set up Node.js diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 0b40b1acaa..62f3445db8 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -36,7 +36,7 @@ jobs: package-manager-cache: false - name: Set up pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11.21.0 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e00a349876..01a250cc7d 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -58,6 +58,24 @@ jobs: python -m pip install --upgrade pip python -m pip install uv + - name: Install Linux sandbox dependency + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap + + - name: Enable and verify Linux sandbox execution + if: runner.os == 'Linux' + run: | + if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + bwrap --unshare-all --ro-bind / / /bin/true + + - name: Verify macOS sandbox dependency + if: runner.os == 'macOS' + run: test -x /usr/bin/sandbox-exec + - name: Run tests run: | chmod +x scripts/run_pytests_ci.sh diff --git a/AGENTS.md b/AGENTS.md index e8ea7ee966..fb759069dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -414,7 +414,9 @@ User/developer documentation is bilingual. A behavior, command, navigation, or configuration change normally requires matching updates under `docs/zh/` and `docs/en/`, plus `docs/.vitepress/config.mjs` when navigation changes. Keep the two languages structurally aligned, but write natural translations rather than -copying stale text. +copying stale text. For renamed, moved, or merged WebUI entry points, include +an old-to-new mapping in the relevant docs (see `docs/zh/use/webui.md` and +`docs/en/use/webui.md`). Validate documentation with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bc7be524..3704bb0bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,8 @@ fork behavior apart from upstream merges: ### v4.28.x -- [v4.28.1](./changelogs/v4.28.1.md) — `[WIP]` +- [v4.28.2](./changelogs/v4.28.2.md) — `[WIP]` +- [v4.28.1](./changelogs/v4.28.1.md) — 2026-09-14 - [v4.28.0](./changelogs/v4.28.0.md) ### v4.27.x diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c04ded8e7f..165e2895d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,8 @@ cd dashboard && pnpm generate:api `make run` 会把文档打进 WebUI 的 `/help/`。不要把文档链接指向上游 `docs.astrbot.app`。 +修改 WebUI 菜单入口、页面结构或界面术语时,在同一个 PR 中更新 `docs/zh` 和 `docs/en` 的相关操作说明。入口改名、移动或合并时,在对应文档中写明「旧入口 → 新入口」,并维护 [WebUI 入口对照](docs/zh/use/webui.md#菜单与旧入口对照)。 + 如果你修改了后端 OpenAPI、接口路由或响应结构,请同时刷新: ```bash @@ -148,6 +150,8 @@ cd dashboard && pnpm generate:api `make run` serves documentation from the WebUI at `/help/`. Do not point documentation links at upstream `docs.astrbot.app`. +When changing WebUI navigation, page structure, or labels, update the affected instructions in `docs/zh` and `docs/en` in the same PR. For renamed, moved, or merged entry points, include an **old entry → new entry** mapping and keep the [WebUI navigation reference](docs/en/use/webui.md#navigation-and-previous-entry-points) current. + If you change backend OpenAPI routes, request schemas, or response schemas, also refresh: ```bash diff --git a/astrbot/__init__.py b/astrbot/__init__.py index edc32ad07c..45ea5ad41c 100644 --- a/astrbot/__init__.py +++ b/astrbot/__init__.py @@ -1,4 +1,4 @@ import logging -__version__ = "4.28.1" +__version__ = "4.28.2" logger = logging.getLogger("astrbot") diff --git a/astrbot/core/agent/request_preparation.py b/astrbot/core/agent/request_preparation.py index c95afec160..f20f87a460 100644 --- a/astrbot/core/agent/request_preparation.py +++ b/astrbot/core/agent/request_preparation.py @@ -30,6 +30,7 @@ _MAX_PREPARED_MEDIA_BYTES = 20 * 1024 * 1024 +_CUA_IMAGE_WARN_BYTES = 5 * 1024 * 1024 def clone_provider_request(request: ProviderRequest) -> ProviderRequest: @@ -70,6 +71,27 @@ def _safe_media_ref(ref: object) -> str | None: return value +def cua_pixel_mode_from_settings( + provider_settings: dict[str, object] | None, +) -> bool: + """Return whether CUA sandbox stills must keep 1:1 pixel coordinates. + + Args: + provider_settings: ``provider_settings`` mapping, or ``None``. + + Returns: + True when Computer Use is the CUA sandbox runtime. + """ + if not isinstance(provider_settings, dict): + return False + sandbox_cfg = provider_settings.get("sandbox") + return ( + provider_settings.get("computer_use_runtime") == "sandbox" + and isinstance(sandbox_cfg, dict) + and sandbox_cfg.get("booter") == "cua" + ) + + def image_compress_args_from_settings( provider_settings: dict[str, object] | None, ) -> tuple[bool, int, int]: @@ -81,6 +103,7 @@ def image_compress_args_from_settings( Returns: ``(enabled, max_size, quality)``. ``enabled`` controls long-edge resize only; JPEG conversion always runs at the choke point. + CUA sandbox stills skip resize so pixel tools stay 1:1. """ if not isinstance(provider_settings, dict): return True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY @@ -88,6 +111,8 @@ def image_compress_args_from_settings( enabled = provider_settings.get("image_compress_enabled", True) if not isinstance(enabled, bool): enabled = True + if cua_pixel_mode_from_settings(provider_settings): + enabled = False raw_options = provider_settings.get("image_compress_options", {}) options = raw_options if isinstance(raw_options, dict) else {} @@ -123,6 +148,7 @@ async def _prepare_media( image_compress_enabled: bool = True, image_max_size: int = IMAGE_COMPRESS_DEFAULT_MAX_SIZE, image_quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY, + warn_unresized_images: bool = False, ) -> tuple[list[str], list[ProviderContentBlock], bool]: """Resolve allowed media to data URLs and report whether anything was dropped.""" prepared_refs: list[str] = [] @@ -139,6 +165,7 @@ async def _prepare_media( image_compress_enabled=image_compress_enabled, image_max_size=image_max_size, image_quality=image_quality, + warn_unresized_images=warn_unresized_images, ) for ref in refs: @@ -191,6 +218,7 @@ async def _prepare_image_refs( image_compress_enabled: bool = True, image_max_size: int = IMAGE_COMPRESS_DEFAULT_MAX_SIZE, image_quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY, + warn_unresized_images: bool = False, ) -> tuple[list[str], list[ProviderContentBlock], bool]: prepared_refs: list[str] = [] blocks: list[ProviderContentBlock] = [] @@ -223,6 +251,15 @@ async def _prepare_image_refs( try: for jpeg_path in jpeg_paths: jpeg_bytes = Path(jpeg_path).read_bytes() + if ( + warn_unresized_images + and _CUA_IMAGE_WARN_BYTES < len(jpeg_bytes) <= max_bytes + ): + logger.warning( + "Prepared image is %.1f MB without resize; " + "this may exceed provider image upload limits.", + len(jpeg_bytes) / 1048576, + ) if len(jpeg_bytes) > max_bytes: logger.warning( "Drop invalid or oversized image provider media." @@ -272,6 +309,7 @@ async def prepare_provider_request( image_compress_enabled: bool = True, image_max_size: int = IMAGE_COMPRESS_DEFAULT_MAX_SIZE, image_quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY, + warn_unresized_images: bool = False, ) -> ProviderRequest: """Return a sanitized, normalized copy suitable for a provider request. @@ -301,6 +339,7 @@ async def prepare_provider_request( image_compress_enabled=image_compress_enabled, image_max_size=image_max_size, image_quality=image_quality, + warn_unresized_images=warn_unresized_images, ) audio_refs, audio_blocks, audio_dropped = await _prepare_media( prepared_request.audio_urls, diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 3b42ec1abb..cd5811c34c 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -23,6 +23,7 @@ from astrbot.core.agent.message import TextPart from astrbot.core.agent.request_preparation import ( clone_provider_request, + cua_pixel_mode_from_settings, image_compress_args_from_settings, prepare_provider_request, ) @@ -2175,6 +2176,9 @@ async def build_main_agent( image_compress_enabled=compress_enabled, image_max_size=image_max_size, image_quality=image_quality, + warn_unresized_images=cua_pixel_mode_from_settings( + config.provider_settings + ), ) event.set_extra("provider_request", req) diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index d4fff0f5dd..a0212c132f 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -1,24 +1,37 @@ +from __future__ import annotations + import asyncio import fnmatch -import hashlib import locale import os import re import shutil import signal +import stat import subprocess import sys +import tempfile +import threading import time import uuid +from _thread import LockType +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, BinaryIO, cast from astrbot import logger from astrbot.core.computer.file_read_utils import ( detect_text_encoding, read_local_text_range_sync, ) +from astrbot.core.computer.local_file_security import read_fd_at +from astrbot.core.computer.process_sandbox import ( + SandboxProcess, + SandboxSpec, + SandboxTimeoutError, + create_process_sandbox, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_root, get_astrbot_system_tmp_path, @@ -37,9 +50,18 @@ re.compile(r"(^|[;&|() ])kill\s+-9(?:\s|$)"), re.compile(r"(^|[;&|() ])killall(?:\s|$)"), ] +_LOCAL_SANDBOX_MAX_OUTPUT_BYTES = 10 * 1024 * 1024 +_SEARCH_FALLBACK_TIMEOUT_SECONDS = 30 +_SEARCH_FALLBACK_MAX_FILES = 4000 +_SEARCH_FALLBACK_MAX_FILE_BYTES = 256 * 1024 def _is_safe_command(command: str) -> bool: + """Return whether the command passes the unsandboxed UX blocklist. + + This is not a security boundary. Host isolation comes from the OS sandbox + when ``sandboxed=True``. + """ normalized_command = re.sub(r"\s+", " ", command.strip().lower()) if ":(){:|:&};:" in normalized_command: return False @@ -143,7 +165,6 @@ class LocalShellComponent(ShellComponent): max_sessions: int = 16 max_output_bytes: int = 4 * 1024 * 1024 session_ttl_seconds: int = 30 * 60 - disk_quota_bytes: int = 32 * 1024 * 1024 async def exec( # noqa: ASYNC109 self, @@ -193,7 +214,7 @@ def _run() -> dict[str, Any]: if background: # `command` is intentionally executed through the current shell so # local computer-use behavior matches existing tool semantics. - # Safety relies on `_is_safe_command()` and the allowed-root checks. + # UX blocklist plus cwd bounds; the OS sandbox is the safety boundary. proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit popen_command, # Controlled local computer-use command. @@ -204,7 +225,7 @@ def _run() -> dict[str, Any]: return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None} # `command` is intentionally executed through the current shell so # local computer-use behavior matches existing tool semantics. - # Safety relies on `_is_safe_command()` and the allowed-root checks. + # UX blocklist plus cwd bounds; the OS sandbox is the safety boundary. proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit popen_command, # Controlled local computer-use command. @@ -265,19 +286,22 @@ async def exec_managed( runtime_id: str = "local", sender_id: str = "", allowed_root: str | None = None, + creator_is_admin: bool = False, + sandboxed: bool = False, + permission_check: Callable[[], bool] | None = None, + allow_network: bool = False, + filesystem_scope: str = "workspace", + readable_roots: tuple[Path, ...] = (), + writable_roots: tuple[Path, ...] = (), ) -> dict[str, Any]: # noqa: ASYNC109 """Start a runtime-owned interactive shell session.""" - if not _is_safe_command(command): + if not sandboxed and not _is_safe_command(command): raise PermissionError("Blocked unsafe shell command.") if not 0 <= yield_time_ms <= 30_000: raise ValueError("yield_time_ms must be between 0 and 30000") if timeout is not None and timeout <= 0: raise ValueError("timeout must be greater than zero") max_output_chars = max(1, min(max_output_chars, 100_000)) - async with self._sessions_lock: - if len(self._sessions) >= self.max_sessions: - raise ValueError("Managed shell session limit reached") - working_dir = Path(cwd or get_astrbot_root()).resolve(strict=False) boundary = Path(allowed_root or get_astrbot_root()).resolve(strict=False) if not working_dir.is_relative_to(boundary): @@ -286,122 +310,129 @@ async def exec_managed( owner_runtime_id = str(runtime_id or "local") owner_sender_id = str(sender_id or "") session_id = f"sh_{uuid.uuid4().hex[:16]}" - owner_digest = hashlib.sha256( - f"{owner_runtime_id}\0{owner_id}\0{owner_sender_id}".encode() - ).hexdigest()[:16] - output_dir = Path(get_astrbot_system_tmp_path()) / "shell" / owner_digest + output_dir = Path(get_astrbot_system_tmp_path()) output_dir.mkdir(parents=True, exist_ok=True) - try: - current_usage = sum( - path.stat().st_size - for path in output_dir.parent.rglob("*.log") - if path.is_file() - ) - except OSError: - current_usage = self.disk_quota_bytes - if current_usage >= self.disk_quota_bytes: - raise ValueError("Managed shell disk quota exceeded") - output_path = output_dir / f"{session_id}.log" - output_path.touch() - - run_env = { - **os.environ, - **{str(k): str(v) for k, v in (env or {}).items()}, - } - if sys.platform == "win32": - # Keep managed-session child output UTF-8 (see LocalShellComponent.exec). - run_env.setdefault("PYTHONIOENCODING", "utf-8") - process_kwargs: dict[str, Any] = { - "stdin": asyncio.subprocess.PIPE, - "stdout": asyncio.subprocess.PIPE, - "stderr": asyncio.subprocess.STDOUT, - "cwd": str(working_dir), - "env": run_env, - } - if sys.platform == "win32": - process_kwargs["creationflags"] = getattr( - subprocess, "CREATE_NEW_PROCESS_GROUP", 0 - ) - else: - process_kwargs["start_new_session"] = True - try: - if sys.platform == "win32": - process = await asyncio.create_subprocess_exec( - resolve_windows_shell(), - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - command, - **process_kwargs, - ) - else: - process = await asyncio.create_subprocess_shell( - command, **process_kwargs + + async with self._sessions_lock: + if len(self._sessions) >= self.max_sessions: + raise ValueError("Managed shell session limit reached") + if permission_check is not None and not permission_check(): + raise PermissionError( + "Local shell permissions changed; retry the command." ) - except BaseException: - output_path.unlink(missing_ok=True) - raise + output_file = tempfile.TemporaryFile(mode="w+b", dir=output_dir) + output_lock = threading.Lock() + try: + if sandboxed: + process = await create_process_sandbox().spawn_shell( + command, + SandboxSpec( + workspace=working_dir, + allow_network=allow_network, + filesystem_scope=filesystem_scope, + readable_roots=readable_roots, + writable_roots=writable_roots, + ), + env={str(k): str(v) for k, v in (env or {}).items()}, + ) + else: + run_env = { + **os.environ, + **{str(k): str(v) for k, v in (env or {}).items()}, + } + process_kwargs: dict[str, Any] = { + "stdin": asyncio.subprocess.PIPE, + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.STDOUT, + "cwd": str(working_dir), + "env": run_env, + } + if sys.platform == "win32": + run_env.setdefault("PYTHONIOENCODING", "utf-8") + process_kwargs["creationflags"] = getattr( + subprocess, "CREATE_NEW_PROCESS_GROUP", 0 + ) + process = await asyncio.create_subprocess_exec( + resolve_windows_shell(), + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + **process_kwargs, + ) + else: + process_kwargs["start_new_session"] = True + process = await asyncio.create_subprocess_shell( + command, **process_kwargs + ) + except BaseException: + output_file.close() + raise - output_event = asyncio.Event() + output_event = asyncio.Event() - async def capture_output() -> None: - if process.stdout is None: - return - try: - with output_path.open("ab") as output_file: + async def capture_output() -> None: + if process.stdout is None: + return + output_size = 0 + try: while chunk := await process.stdout.read(8192): - if output_path.stat().st_size >= self.max_output_bytes: - await self._terminate_process(process) - break - try: - total_usage = sum( - path.stat().st_size - for path in output_path.parent.parent.rglob("*.log") - if path.is_file() - ) - except OSError: - total_usage = self.disk_quota_bytes - if total_usage >= self.disk_quota_bytes: - await self._terminate_process(process) - break - remaining = self.max_output_bytes - output_path.stat().st_size - remaining = min( - remaining, - self.disk_quota_bytes - total_usage, - ) - output_file.write(chunk[:remaining]) - output_file.flush() + remaining = self.max_output_bytes - output_size + if remaining <= 0: + session.output_limited = True + await self._terminate_process(session) + return + if len(chunk) > remaining: + chunk = chunk[:remaining] + session.output_limited = True + with output_lock: + output_file.seek(0, os.SEEK_END) + output_file.write(chunk) + output_file.flush() + output_size += len(chunk) output_event.set() - except asyncio.CancelledError: - raise + if session.output_limited: + await self._terminate_process(session) + return + except asyncio.CancelledError: + raise + + reader_task = asyncio.create_task( + capture_output(), name=f"shell-reader-{session_id}" + ) + wait_task = asyncio.create_task( + process.wait(), name=f"shell-wait-{session_id}" + ) + wait_task.add_done_callback(lambda _: output_event.set()) + session = _LocalShellSession( + session_id=session_id, + runtime_id=owner_runtime_id, + umo=owner_id, + sender_id=owner_sender_id, + creator_is_admin=creator_is_admin, + sandboxed=sandboxed, + process=process, + output_file=output_file, + output_lock=output_lock, + started_at=time.monotonic(), + last_activity=time.monotonic(), + output_event=output_event, + reader_task=reader_task, + wait_task=wait_task, + permission_check=permission_check, + ) + self._sessions[session_id] = session - reader_task = asyncio.create_task( - capture_output(), name=f"shell-reader-{session_id}" - ) - wait_task = asyncio.create_task(process.wait(), name=f"shell-wait-{session_id}") - wait_task.add_done_callback(lambda _: output_event.set()) - session = _LocalShellSession( - session_id=session_id, - runtime_id=owner_runtime_id, - umo=owner_id, - sender_id=owner_sender_id, - process=process, - output_path=output_path, - started_at=time.monotonic(), - last_activity=time.monotonic(), - output_event=output_event, - reader_task=reader_task, - wait_task=wait_task, - ) + if permission_check is not None and not permission_check(): + await self.shutdown_sessions(invalid_only=True) + raise PermissionError("Local shell permissions changed; retry the command.") effective_ttl = timeout if timeout is not None else self.session_ttl_seconds if effective_ttl > 0: session.timeout_task = asyncio.create_task( self._timeout_session(session, effective_ttl), name=f"shell-timeout-{session_id}", ) - async with self._sessions_lock: - self._sessions[session_id] = session if yield_time_ms: try: await asyncio.wait_for(asyncio.shield(wait_task), yield_time_ms / 1000) @@ -447,14 +478,16 @@ async def poll_session( async def read_output() -> tuple[bytes, int, int]: def _read() -> tuple[bytes, int, int]: - try: - size = session.output_path.stat().st_size - except OSError: - return b"", read_cursor, read_cursor - start = min(read_cursor, size) - with session.output_path.open("rb") as output_file: - output_file.seek(start) - data = output_file.read(max_output_chars) + with session.output_lock: + if session.output_file.closed: + return b"", read_cursor, read_cursor + try: + size = os.fstat(session.output_file.fileno()).st_size + except OSError, ValueError: + return b"", read_cursor, read_cursor + start = min(read_cursor, size) + session.output_file.seek(start) + data = session.output_file.read(max_output_chars) return data, start + len(data), size return await asyncio.to_thread(_read) @@ -537,14 +570,17 @@ async def interrupt_session( session_id, owner_id, runtime_id, sender_id ) if session.process.returncode is None: - if sys.platform == "win32": - session.process.send_signal( - getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM) - ) + if session.sandboxed: + cast(SandboxProcess, session.process).interrupt() else: - if not _signal_posix_process_group(session.process.pid, signal.SIGINT): + native_process = cast(asyncio.subprocess.Process, session.process) + if sys.platform == "win32": + native_process.send_signal( + getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM) + ) + elif not _signal_posix_process_group(native_process.pid, signal.SIGINT): try: - session.process.send_signal(signal.SIGINT) + native_process.send_signal(signal.SIGINT) except ProcessLookupError: pass return await self.poll_session( @@ -569,7 +605,7 @@ async def terminate_session( session_id, owner_id, runtime_id, sender_id ) session.terminated = True - await self._terminate_process(session.process) + await self._terminate_process(session) return await self.poll_session( owner_id=owner_id, session_id=session_id, @@ -578,14 +614,34 @@ async def terminate_session( sender_id=sender_id, ) - async def shutdown_sessions(self) -> None: + async def shutdown_sessions(self, *, invalid_only: bool = False) -> None: async with self._sessions_lock: - sessions = list(self._sessions.values()) - for session in sessions: - session.terminated = True - for session in sessions: - await self._terminate_process(session.process) - await asyncio.gather(*(session.reader_task for session in sessions)) + sessions = [] + for session in self._sessions.values(): + permission_check = session.permission_check + if ( + not invalid_only + or permission_check is None + or not permission_check() + ): + sessions.append(session) + for session in sessions: + session.terminated = True + termination_results = await asyncio.gather( + *(self._terminate_process(session) for session in sessions), + return_exceptions=True, + ) + for session, result in zip(sessions, termination_results, strict=True): + if isinstance(result, BaseException): + logger.warning( + "Failed to terminate managed local shell session %s: %s", + session.session_id, + result, + ) + await asyncio.gather( + *(session.reader_task for session in sessions), + return_exceptions=True, + ) for session in sessions: await self._remove_session(session) @@ -599,7 +655,7 @@ async def _timeout_session( # noqa: ASYNC109 remaining = timeout - (time.monotonic() - session.last_activity) if remaining <= 0: session.timed_out = True - await self._terminate_process(session.process) + await self._terminate_process(session) return try: await asyncio.wait_for(asyncio.shield(session.wait_task), remaining) @@ -631,6 +687,13 @@ async def _get_owned_session( sender_id, ): raise ValueError("Shell session was not found") + permission_check = session.permission_check + if permission_check is not None and not permission_check(): + await self.shutdown_sessions(invalid_only=True) + raise ValueError( + "Shell session expired after a permission change. " + "Start a new shell session." + ) return session def _session_status(self, session: _LocalShellSession) -> str: @@ -638,14 +701,16 @@ def _session_status(self, session: _LocalShellSession) -> str: return "running" if session.timed_out: return "timed_out" + if session.output_limited: + return "output_limited" if session.terminated: return "terminated" return "completed" if session.process.returncode == 0 else "failed" def _session_summary(self, session: _LocalShellSession) -> dict[str, Any]: try: - size = session.output_path.stat().st_size - except OSError: + size = os.fstat(session.output_file.fileno()).st_size + except OSError, ValueError: size = session.cursor return { "session_id": session.session_id, @@ -653,12 +718,14 @@ def _session_summary(self, session: _LocalShellSession) -> dict[str, Any]: "status": self._session_status(session), "exit_code": session.process.returncode, "started_at": session.started_at, + "sandboxed": session.sandboxed, "unread_output_bytes": max(0, size - session.cursor), } async def _remove_session(self, session: _LocalShellSession) -> None: async with self._sessions_lock: - self._sessions.pop(session.session_id, None) + if self._sessions.get(session.session_id) is session: + self._sessions.pop(session.session_id, None) if ( session.timeout_task and not session.timeout_task.done() @@ -669,41 +736,47 @@ async def _remove_session(self, session: _LocalShellSession) -> None: await session.timeout_task except asyncio.CancelledError: pass - session.output_path.unlink(missing_ok=True) - try: - session.output_path.parent.rmdir() - except OSError: - pass + with session.output_lock: + session.output_file.close() - async def _terminate_process(self, process: asyncio.subprocess.Process) -> None: + async def _terminate_process(self, session: _LocalShellSession) -> None: + process = session.process if process.returncode is not None: return - if sys.platform == "win32": - try: - result = await asyncio.to_thread( - subprocess.run, - ["taskkill", "/F", "/T", "/PID", str(process.pid)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=5, - ) - if result.returncode != 0: - _signal_asyncio_process(process, terminate=True) - except Exception: - _signal_asyncio_process(process, terminate=True) + if session.sandboxed: + process.terminate() else: - if not _signal_posix_process_group(process.pid, signal.SIGTERM): - _signal_asyncio_process(process, terminate=True) + native_process = cast(asyncio.subprocess.Process, process) + if sys.platform == "win32": + try: + result = await asyncio.to_thread( + subprocess.run, + ["taskkill", "/F", "/T", "/PID", str(native_process.pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + if result.returncode != 0: + _signal_asyncio_process(native_process, terminate=True) + except Exception: + _signal_asyncio_process(native_process, terminate=True) + elif not _signal_posix_process_group(native_process.pid, signal.SIGTERM): + _signal_asyncio_process(native_process, terminate=True) try: - await asyncio.wait_for(process.wait(), 5) + await asyncio.wait_for(asyncio.shield(session.wait_task), 5) except TimeoutError: - if sys.platform == "win32": - _signal_asyncio_process(process, terminate=False) + if session.sandboxed: + process.kill() else: - if not _signal_posix_process_group(process.pid, signal.SIGKILL): - _signal_asyncio_process(process, terminate=False) + native_process = cast(asyncio.subprocess.Process, process) + if sys.platform == "win32": + _signal_asyncio_process(native_process, terminate=False) + elif not _signal_posix_process_group( + native_process.pid, signal.SIGKILL + ): + _signal_asyncio_process(native_process, terminate=False) try: - await process.wait() + await session.wait_task except ProcessLookupError: return except ProcessLookupError: @@ -716,17 +789,22 @@ class _LocalShellSession: runtime_id: str umo: str sender_id: str - process: asyncio.subprocess.Process - output_path: Path + creator_is_admin: bool + sandboxed: bool + process: SandboxProcess | asyncio.subprocess.Process + output_file: BinaryIO + output_lock: LockType started_at: float output_event: asyncio.Event reader_task: asyncio.Task wait_task: asyncio.Task last_activity: float + permission_check: Callable[[], bool] | None = None timeout_task: asyncio.Task | None = None cursor: int = 0 timed_out: bool = False terminated: bool = False + output_limited: bool = False @dataclass @@ -738,34 +816,65 @@ async def exec( timeout_seconds: int = 30, silent: bool = False, cwd: str | None = None, + sandboxed: bool = False, + allow_network: bool = False, + filesystem_scope: str = "workspace", + readable_roots: tuple[Path, ...] = (), + writable_roots: tuple[Path, ...] = (), ) -> dict[str, Any]: def _run() -> dict[str, Any]: try: - working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root() - child_env = os.environ.copy() - if sys.platform == "win32": - # Keep python tool output UTF-8 (see LocalShellComponent.exec). - child_env.setdefault("PYTHONIOENCODING", "utf-8") - result = subprocess.run( - [os.environ.get("PYTHON", sys.executable), "-c", code], - timeout=timeout_seconds, - capture_output=True, - cwd=working_dir, - env=child_env, - ) - stdout = "" if silent else _decode_shell_output(result.stdout) - stderr = ( - _decode_shell_output(result.stderr) - if result.returncode != 0 + working_dir = Path(cwd).resolve() if cwd else Path(get_astrbot_root()) + if sandboxed: + result = create_process_sandbox().run( + [sys.executable, "-c", code], + SandboxSpec( + workspace=working_dir, + allow_network=allow_network, + filesystem_scope=filesystem_scope, + readable_roots=readable_roots, + writable_roots=writable_roots, + ), + timeout=timeout_seconds, + output_limit=_LOCAL_SANDBOX_MAX_OUTPUT_BYTES, + discard_stdout=silent, + ) + stdout = _decode_shell_output(result.stdout) + stderr = _decode_shell_output(result.stderr) + stdout_limited = result.stdout_limited + stderr_limited = result.stderr_limited + else: + child_env = os.environ.copy() + if sys.platform == "win32": + child_env.setdefault("PYTHONIOENCODING", "utf-8") + result = subprocess.run( + [os.environ.get("PYTHON", sys.executable), "-c", code], + timeout=timeout_seconds, + capture_output=True, + cwd=working_dir, + env=child_env, + ) + stdout = "" if silent else _decode_shell_output(result.stdout) + stderr = _decode_shell_output(result.stderr) + stdout_limited = False + stderr_limited = False + if stdout_limited or stderr_limited: + stderr = ( + f"{stderr}\nExecution output exceeded " + f"{_LOCAL_SANDBOX_MAX_OUTPUT_BYTES} bytes." + ).strip() + execution_error = ( + stderr + if result.returncode != 0 or stdout_limited or stderr_limited else "" ) return { "data": { "output": {"text": stdout, "images": []}, - "error": stderr, + "error": execution_error, } } - except subprocess.TimeoutExpired: + except SandboxTimeoutError, subprocess.TimeoutExpired: return { "data": { "output": {"text": "", "images": []}, @@ -797,13 +906,17 @@ async def read_file( encoding: str = "utf-8", offset: int | None = None, limit: int | None = None, + file_descriptor: int | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = os.path.abspath(path) detected_encoding = encoding if encoding == "utf-8": - with open(abs_path, "rb") as f: - raw_sample = f.read(8192) + if file_descriptor is None: + with open(abs_path, "rb") as f: + raw_sample = f.read(8192) + else: + raw_sample = read_fd_at(file_descriptor, 8192, 0) detected_encoding = detect_text_encoding(raw_sample) or encoding return { "success": True, @@ -812,6 +925,7 @@ def _run() -> dict[str, Any]: encoding=detected_encoding, offset=offset, limit=limit, + file_descriptor=file_descriptor, ), } @@ -824,9 +938,75 @@ async def search_files( glob: str | None = None, after_context: int | None = None, before_context: int | None = None, + sandboxed: bool = False, + sandbox_root: str | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: search_path = Path(path or get_astrbot_root()).resolve(strict=False) + if sandboxed: + if not sandbox_root: + return { + "success": False, + "content": "", + "error": "A sandbox root is required for restricted Local search.", + } + rg_path = shutil.which("rg") + if not rg_path: + return { + "success": False, + "content": "", + "error": "The ripgrep (rg) executable is required for sandboxed file search.", + } + command = [ + str(Path(rg_path).resolve()), + "--color=never", + "-n", + "--max-columns", + "1000", + "-e", + pattern, + ] + if glob: + command.extend(["-g", glob]) + if after_context is not None: + command.extend(["-A", str(after_context)]) + if before_context is not None: + command.extend(["-B", str(before_context)]) + command.extend(["--", str(search_path)]) + try: + result = create_process_sandbox().run( + command, + SandboxSpec( + workspace=Path(sandbox_root), + workspace_writable=False, + ), + timeout=30, + ) + except (SandboxTimeoutError, OSError) as exc: + return { + "success": False, + "content": "", + "error": ( + "File search timed out after 30 seconds." + if isinstance(exc, SandboxTimeoutError) + else f"Unable to start ripgrep: {exc}" + ), + } + stdout = _decode_shell_output(result.stdout) + if result.returncode in (0, 1): + return { + "success": True, + "content": _truncate_long_lines( + stdout if result.returncode == 0 else "" + ), + } + return { + "success": False, + "content": "", + "error": _decode_shell_output(result.stderr) + or f"ripgrep exited with code {result.returncode}", + "exit_code": result.returncode, + } rg_path = shutil.which("rg") if rg_path: command = [ @@ -846,11 +1026,19 @@ def _run() -> dict[str, Any]: command.extend(["-B", str(before_context)]) command.extend(["--", str(search_path)]) - result = subprocess.run( - command, - capture_output=True, - cwd=get_astrbot_root(), - ) + try: + result = subprocess.run( + command, + capture_output=True, + cwd=get_astrbot_root(), + timeout=30, + ) + except subprocess.TimeoutExpired: + return { + "success": False, + "content": "", + "error": "File search timed out after 30 seconds.", + } if result.returncode in (0, 1): return { "success": True, @@ -868,18 +1056,37 @@ def _run() -> dict[str, Any]: matcher = re.compile(pattern) output_lines: list[str] = [] - paths = ( - [search_path] - if search_path.is_file() - else sorted( - path_ for path_ in search_path.rglob("*") if path_.is_file() - ) - ) + deadline = time.monotonic() + _SEARCH_FALLBACK_TIMEOUT_SECONDS + scanned = 0 + paths = [search_path] if search_path.is_file() else search_path.rglob("*") for file_path in paths: + if time.monotonic() >= deadline: + return { + "success": False, + "content": "", + "error": "File search timed out after 30 seconds.", + } + scanned += 1 + if scanned > _SEARCH_FALLBACK_MAX_FILES: + return { + "success": False, + "content": "", + "error": "File search exceeded the fallback file-count limit.", + } if glob and not fnmatch.fnmatch(file_path.name, glob): continue try: - text = file_path.read_text(encoding="utf-8", errors="ignore") + info = file_path.lstat() + if not stat.S_ISREG(info.st_mode): + continue + if info.st_size > _SEARCH_FALLBACK_MAX_FILE_BYTES: + continue + resolved = file_path.resolve(strict=True) + if resolved != search_path and not resolved.is_relative_to( + search_path + ): + continue + text = resolved.read_text(encoding="utf-8", errors="ignore") except OSError: continue @@ -929,24 +1136,43 @@ async def edit_file( new_string: str, replace_all: bool = False, encoding: str = "utf-8", + file_descriptor: int | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = os.path.abspath(path) - with open(abs_path, encoding=encoding) as f: - content = f.read() - occurrences = content.count(old_string) - if occurrences == 0: - return { - "success": False, - "error": "old string not found in file", - "replacements": 0, - } - if replace_all: - updated = content.replace(old_string, new_string) - replacements = occurrences + if file_descriptor is None: + file_obj = open(abs_path, encoding=encoding) else: - updated = content.replace(old_string, new_string, 1) - replacements = 1 + file_obj = os.fdopen( + os.dup(file_descriptor), + mode="r+", + encoding=encoding, + ) + file_obj.seek(0) + with file_obj as f: + content = f.read() + occurrences = content.count(old_string) + if occurrences == 0: + return { + "success": False, + "error": "old string not found in file", + "replacements": 0, + } + if replace_all: + updated = content.replace(old_string, new_string) + replacements = occurrences + else: + updated = content.replace(old_string, new_string, 1) + replacements = 1 + if file_descriptor is not None: + f.seek(0) + f.truncate() + f.write(updated) + return { + "success": True, + "path": abs_path, + "replacements": replacements, + } with open(abs_path, "w", encoding=encoding) as f: f.write(updated) return { @@ -958,12 +1184,30 @@ def _run() -> dict[str, Any]: return await asyncio.to_thread(_run) async def write_file( - self, path: str, content: str, mode: str = "w", encoding: str = "utf-8" + self, + path: str, + content: str, + mode: str = "w", + encoding: str = "utf-8", + file_descriptor: int | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = os.path.abspath(path) - os.makedirs(os.path.dirname(abs_path), exist_ok=True) - with open(abs_path, mode, encoding=encoding) as f: + if file_descriptor is None: + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + file_obj = open(abs_path, mode, encoding=encoding) + else: + file_obj = os.fdopen( + os.dup(file_descriptor), + mode=mode, + encoding=encoding, + ) + if mode == "w": + file_obj.seek(0) + file_obj.truncate() + elif mode == "a": + file_obj.seek(0, os.SEEK_END) + with file_obj as f: f.write(content) return {"success": True, "path": abs_path} diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py index db027709d1..ddc09940a0 100644 --- a/astrbot/core/computer/file_read_utils.py +++ b/astrbot/core/computer/file_read_utils.py @@ -2,6 +2,7 @@ import hashlib import io import json +import os import zipfile from asyncio import to_thread from dataclasses import dataclass @@ -22,10 +23,12 @@ ) from .booters.base import ComputerBooter +from .local_file_security import open_file_in_allowed_roots, read_fd_at _MAX_FILE_READ_BYTES = 128 * 1024 _MAX_FILE_READ_TOKENS = 25_000 _MAX_TEXT_FILE_FULL_READ_BYTES = 256 * 1024 +_MAX_LOCAL_DOCUMENT_BYTES = 32 * 1024 * 1024 _FILE_SNIFF_BYTES = 512 _TOKEN_COUNTER = EstimateTokenCounter() _TEXT_ENCODINGS = ( @@ -208,13 +211,22 @@ def read_local_text_range_sync( encoding: str, offset: int | None, limit: int | None, + file_descriptor: int | None = None, ) -> str: lines: list[str] = [] start = 0 if offset is None else offset end = None if limit is None else start + limit - # Default universal newlines so CRLF files read back with "\n" on every - # platform. - with open(path, encoding=encoding) as file_obj: + # Normalize CRLF with universal newlines for both paths and safe handles. + if file_descriptor is None: + file_obj = open(path, encoding=encoding) + else: + file_obj = os.fdopen( + os.dup(file_descriptor), + mode="r", + encoding=encoding, + ) + file_obj.seek(0) + with file_obj: for index, line in enumerate(file_obj): if index < start: continue @@ -230,6 +242,7 @@ async def read_local_text_range( encoding: str, offset: int | None, limit: int | None, + file_descriptor: int | None = None, ) -> str: return await to_thread( read_local_text_range_sync, @@ -237,6 +250,7 @@ async def read_local_text_range( encoding=encoding, offset=offset, limit=limit, + file_descriptor=file_descriptor, ) @@ -271,8 +285,18 @@ async def _exec_python_json( return payload -async def _probe_local_file(path: str) -> dict[str, str | int]: +async def _probe_local_file( + path: str, + file_descriptor: int | None = None, +) -> dict[str, str | int]: def _run() -> dict[str, str | int]: + if file_descriptor is not None: + return { + "size_bytes": os.fstat(file_descriptor).st_size, + "sample_b64": base64.b64encode( + read_fd_at(file_descriptor, _FILE_SNIFF_BYTES, 0) + ).decode("utf-8"), + } file_path = Path(path) with file_path.open("rb") as file_obj: sample = file_obj.read(_FILE_SNIFF_BYTES) @@ -284,9 +308,39 @@ def _run() -> dict[str, str | int]: return await to_thread(_run) -async def _read_local_image_base64(path: str) -> dict[str, str | int]: +def _local_file_size(path: str, file_descriptor: int | None) -> int: + if file_descriptor is None: + return Path(path).stat().st_size + return os.fstat(file_descriptor).st_size + + +def _reject_oversized_local_file(path: str, file_descriptor: int | None) -> None: + size_bytes = _local_file_size(path, file_descriptor) + if size_bytes > _MAX_LOCAL_DOCUMENT_BYTES: + raise ValueError( + f"File is {size_bytes} bytes; local document and image reads are " + f"limited to {_MAX_LOCAL_DOCUMENT_BYTES} bytes." + ) + + +async def _read_local_image_base64( + path: str, + file_descriptor: int | None = None, +) -> dict[str, str | int]: def _run() -> dict[str, str | int]: - data = Path(path).read_bytes() + _reject_oversized_local_file(path, file_descriptor) + if file_descriptor is None: + with Path(path).open("rb") as file_obj: + data = file_obj.read(_MAX_LOCAL_DOCUMENT_BYTES + 1) + else: + with os.fdopen(os.dup(file_descriptor), "rb") as file_obj: + file_obj.seek(0) + data = file_obj.read(_MAX_LOCAL_DOCUMENT_BYTES + 1) + if len(data) > _MAX_LOCAL_DOCUMENT_BYTES: + raise ValueError( + f"File is {len(data)} bytes; local document and image reads are " + f"limited to {_MAX_LOCAL_DOCUMENT_BYTES} bytes." + ) return { "size_bytes": len(data), "base64": base64.b64encode(data).decode("utf-8"), @@ -295,8 +349,27 @@ def _run() -> dict[str, str | int]: return await to_thread(_run) -async def _read_local_file_bytes(path: str) -> bytes: - return await to_thread(Path(path).read_bytes) +async def _read_local_file_bytes( + path: str, + file_descriptor: int | None = None, +) -> bytes: + def _run() -> bytes: + _reject_oversized_local_file(path, file_descriptor) + if file_descriptor is None: + with Path(path).open("rb") as file_obj: + data = file_obj.read(_MAX_LOCAL_DOCUMENT_BYTES + 1) + else: + with os.fdopen(os.dup(file_descriptor), "rb") as file_obj: + file_obj.seek(0) + data = file_obj.read(_MAX_LOCAL_DOCUMENT_BYTES + 1) + if len(data) > _MAX_LOCAL_DOCUMENT_BYTES: + raise ValueError( + f"File is {len(data)} bytes; local document and image reads are " + f"limited to {_MAX_LOCAL_DOCUMENT_BYTES} bytes." + ) + return data + + return await to_thread(_run) async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]: @@ -415,30 +488,31 @@ async def _parse_local_epub_text(file_bytes: bytes, file_name: str) -> str: async def _parse_local_supported_document( path: str, sample: bytes, + file_descriptor: int | None = None, ) -> ParsedDocument | None: file_name = Path(path).name suffix = Path(path).suffix.lower() if _looks_like_pdf(path, sample): - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) text = await _parse_local_pdf_text(file_bytes, file_name) return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text) if suffix == ".epub": - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) if not _is_epub_bytes(file_bytes): return None text = await _parse_local_epub_text(file_bytes, file_name) return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text) if suffix == ".docx": - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) if not _is_docx_bytes(file_bytes): return None text = await _parse_local_docx_text(file_bytes, file_name) return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text) if _looks_like_zip_container(sample): - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) if _is_epub_bytes(file_bytes): text = await _parse_local_epub_text(file_bytes, file_name) return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text) @@ -540,6 +614,7 @@ async def _store_converted_text_for_workspace( original_path: str, original_bytes: bytes, content: str, + restricted: bool, ) -> str: def _run() -> str: original_name = Path(original_path).name @@ -549,9 +624,28 @@ def _run() -> str: target_dir = ( Path(workspace_dir) / "converted_files" / f"{original_name}_{digest_suffix}" ) - target_dir.mkdir(parents=True, exist_ok=True) target_path = target_dir / "text.txt" - target_path.write_text(content, encoding="utf-8") + if restricted: + target_fd = open_file_in_allowed_roots( + str(target_path), + (Path(workspace_dir),), + access="write", + create_parents=True, + ) + try: + os.ftruncate(target_fd, 0) + os.lseek(target_fd, 0, os.SEEK_SET) + with os.fdopen( + os.dup(target_fd), + mode="w", + encoding="utf-8", + ) as file_obj: + file_obj.write(content) + finally: + os.close(target_fd) + else: + target_dir.mkdir(parents=True, exist_ok=True) + target_path.write_text(content, encoding="utf-8") return str(target_path) return await to_thread(_run) @@ -591,6 +685,7 @@ async def _read_local_supported_document_result( workspace_dir: str | None, offset: int | None, limit: int | None, + restricted: bool, ) -> ToolExecResult: content = parsed_document.text if not content: @@ -615,6 +710,7 @@ async def _read_local_supported_document_result( original_path=path, original_bytes=parsed_document.file_bytes, content=content, + restricted=restricted, ) if offset is None and limit is None: @@ -658,9 +754,10 @@ async def read_file_tool_result( offset: int | None, limit: int | None, workspace_dir: str | None = None, + local_file_descriptor: int | None = None, ) -> ToolExecResult: if local_mode: - probe_payload = await _probe_local_file(path) + probe_payload = await _probe_local_file(path, local_file_descriptor) else: probe_payload = await _exec_python_json( booter, @@ -674,7 +771,11 @@ async def read_file_tool_result( if local_mode: try: - parsed_document = await _parse_local_supported_document(path, sample) + parsed_document = await _parse_local_supported_document( + path, + sample, + local_file_descriptor, + ) except Exception as exc: return f"Error reading file: failed to parse document: {exc}" @@ -685,6 +786,7 @@ async def read_file_tool_result( workspace_dir=workspace_dir, offset=offset, limit=limit, + restricted=local_file_descriptor is not None, ) if probe.kind == "binary": @@ -692,7 +794,10 @@ async def read_file_tool_result( if probe.kind == "image": if local_mode: - image_payload = await _read_local_image_base64(path) + image_payload = await _read_local_image_base64( + path, + local_file_descriptor, + ) else: image_payload = await _exec_python_json( booter, @@ -729,6 +834,7 @@ async def read_file_tool_result( encoding=probe.encoding or "utf-8", offset=offset, limit=limit, + file_descriptor=local_file_descriptor, ) else: text_payload = await _exec_python_json( diff --git a/astrbot/core/computer/local_file_security.py b/astrbot/core/computer/local_file_security.py new file mode 100644 index 0000000000..5010c0df15 --- /dev/null +++ b/astrbot/core/computer/local_file_security.py @@ -0,0 +1,377 @@ +"""Race-resistant file opening for restricted Local computer tools.""" + +from __future__ import annotations + +import errno +import os +import stat +from pathlib import Path +from typing import Literal, NoReturn + + +def _descriptor_relative_access_available() -> bool: + return not ( + os.name == "nt" + or not hasattr(os, "O_DIRECTORY") + or not hasattr(os, "O_NOFOLLOW") + or os.open not in os.supports_dir_fd + or os.mkdir not in os.supports_dir_fd + ) + + +def _is_path_link(path: Path) -> bool: + return path.is_symlink() or (os.name == "nt" and path.is_junction()) + + +def _match_allowed_root( + candidate: Path, + allowed_roots: tuple[Path, ...], + path: str, +) -> tuple[Path, tuple[str, ...]]: + if not candidate.is_absolute(): + raise PermissionError(f"Restricted file path must be absolute: {path}.") + + root_matches: list[tuple[Path, Path]] = [] + for root in allowed_roots: + try: + root_matches.append((root, candidate.relative_to(root))) + except ValueError: + continue + if not root_matches: + raise PermissionError( + f"Access denied: path is outside restricted roots: {path}." + ) + + root, relative_path = max(root_matches, key=lambda item: len(item[0].parts)) + parts = relative_path.parts + if not parts: + raise IsADirectoryError(path) + if any(part in {"", ".", ".."} for part in parts): + raise PermissionError(f"Access denied: unsafe restricted path: {path}.") + return root, parts + + +def _access_file_flags(access: Literal["read", "write", "edit"]) -> int: + if access == "read": + return os.O_RDONLY + if access == "write": + return os.O_WRONLY + if access == "edit": + return os.O_RDWR + raise ValueError(f"Unsupported restricted file access mode: {access}.") + + +def _validate_opened_file(file_fd: int, path: str) -> None: + file_stat = os.fstat(file_fd) + if not stat.S_ISREG(file_stat.st_mode): + if stat.S_ISDIR(file_stat.st_mode): + raise IsADirectoryError(path) + raise PermissionError( + f"Access denied: restricted path is not a regular file: {path}." + ) + if file_stat.st_nlink > 1: + raise PermissionError( + "Access denied: file has multiple hard links and may alias content " + f"outside allowed directories. Link count: {file_stat.st_nlink}. " + f"Blocked path: {path}." + ) + + +def read_fd_at(file_descriptor: int, size: int, offset: int) -> bytes: + """Read bytes at an offset without requiring Unix ``os.pread``.""" + pread = getattr(os, "pread", None) + if pread is not None: + return pread(file_descriptor, size, offset) + current = os.lseek(file_descriptor, 0, os.SEEK_CUR) + try: + os.lseek(file_descriptor, offset, os.SEEK_SET) + return os.read(file_descriptor, size) + finally: + os.lseek(file_descriptor, current, os.SEEK_SET) + + +def _raise_if_link(path: Path, original: str, *, root: bool = False) -> None: + if not _is_path_link(path): + return + if root: + raise PermissionError( + f"Access denied: restricted root changed or is a symbolic link: {path}." + ) + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {original}." + ) + + +def _ensure_compat_directory(path: Path, original: str, *, create: bool) -> None: + try: + info = path.lstat() + except FileNotFoundError: + if not create: + raise + path.mkdir(mode=0o755, exist_ok=True) + info = path.lstat() + _raise_if_link(path, original) + if not stat.S_ISDIR(info.st_mode): + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {original}." + ) + + +def _nt_final_path(file_fd: int) -> Path: + import ctypes + import msvcrt + + handle = msvcrt.get_osfhandle(file_fd) # type: ignore[attr-defined] + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined] + get_final_path = kernel32.GetFinalPathNameByHandleW + get_final_path.argtypes = [ + ctypes.c_void_p, + ctypes.c_wchar_p, + ctypes.c_uint, + ctypes.c_uint, + ] + get_final_path.restype = ctypes.c_uint + buffer = ctypes.create_unicode_buffer(32768) + length = get_final_path(handle, buffer, len(buffer), 0) + if length == 0 or length >= len(buffer): + raise PermissionError("Access denied: unable to resolve opened file path.") + final = buffer.value + if final.startswith("\\\\?\\"): + final = final[4:] + return Path(final) + + +def _confirm_opened_path( + file_fd: int, + allowed_roots: tuple[Path, ...], + path: str, +) -> None: + if os.name != "nt": + return + try: + opened = _nt_final_path(file_fd) + except OSError as exc: + raise PermissionError( + f"Access denied: unable to resolve opened file path: {path}." + ) from exc + _match_allowed_root(opened, allowed_roots, path) + + +def _open_file_without_dir_fd( + path: str, + root: Path, + parts: tuple[str, ...], + allowed_roots: tuple[Path, ...], + *, + access: Literal["read", "write", "edit"], + create_parents: bool, +) -> int: + current = root + _raise_if_link(current, path, root=True) + if not current.is_dir(): + raise PermissionError( + f"Access denied: restricted root changed or is a symbolic link: {root}." + ) + for component in parts[:-1]: + current = current / component + _ensure_compat_directory(current, path, create=create_parents) + + final_path = current / parts[-1] + file_flags = _access_file_flags(access) + file_flags |= ( + getattr(os, "O_BINARY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + info = final_path.lstat() + except FileNotFoundError: + if not create_parents: + raise + try: + file_fd = os.open( + final_path, + file_flags | os.O_CREAT | os.O_EXCL, + 0o600, + ) + except FileExistsError: + _raise_if_link(final_path, path) + file_fd = os.open(final_path, file_flags) + try: + _confirm_opened_path(file_fd, allowed_roots, path) + _validate_opened_file(file_fd, path) + return file_fd + except BaseException: + os.close(file_fd) + raise + + _raise_if_link(final_path, path) + if not stat.S_ISREG(info.st_mode): + if stat.S_ISDIR(info.st_mode): + raise IsADirectoryError(path) + raise PermissionError( + f"Access denied: restricted path is not a regular file: {path}." + ) + file_fd = os.open(final_path, file_flags) + try: + _confirm_opened_path(file_fd, allowed_roots, path) + _validate_opened_file(file_fd, path) + return file_fd + except BaseException: + os.close(file_fd) + raise + + +def _raise_if_link_oserror(exc: OSError, path: str) -> NoReturn: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {path}." + ) from exc + raise exc + + +def _open_nofollow_directory( + name: str, + directory_fd: int, + directory_flags: int, + *, + path: str, + create: bool, +) -> int: + try: + return os.open(name, directory_flags, dir_fd=directory_fd) + except FileNotFoundError: + if not create: + raise + try: + os.mkdir(name, mode=0o755, dir_fd=directory_fd) + except FileExistsError: + try: + return os.open(name, directory_flags, dir_fd=directory_fd) + except OSError as exc: + _raise_if_link_oserror(exc, path) + try: + return os.open(name, directory_flags, dir_fd=directory_fd) + except OSError as exc: + _raise_if_link_oserror(exc, path) + except OSError as exc: + _raise_if_link_oserror(exc, path) + + +def _open_nofollow_file( + name: str, + directory_fd: int, + file_flags: int, + *, + path: str, + create: bool, +) -> int: + try: + return os.open(name, file_flags, dir_fd=directory_fd) + except FileNotFoundError: + if not create: + raise + try: + return os.open( + name, + file_flags | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=directory_fd, + ) + except FileExistsError: + try: + return os.open(name, file_flags, dir_fd=directory_fd) + except OSError as exc: + _raise_if_link_oserror(exc, path) + except OSError as exc: + _raise_if_link_oserror(exc, path) + + +def open_file_in_allowed_roots( + path: str, + allowed_roots: tuple[Path, ...], + *, + access: Literal["read", "write", "edit"], + create_parents: bool = False, +) -> int: + """Open a regular file without following attacker-controlled path links. + + Args: + path: Absolute normalized file path selected by the caller. + allowed_roots: Trusted directories that may contain the file. + access: Whether the descriptor is used for reading, writing, or editing. + create_parents: Whether missing parent directories and the final file may + be created. + + Returns: + An open file descriptor owned by the caller. + + Raises: + FileNotFoundError: If a required path component does not exist. + IsADirectoryError: If the final path is a directory. + PermissionError: If the path leaves the allowed roots, contains a symbolic + link, is not a regular file, or aliases a multiply linked file. + ValueError: If an unsupported access mode is requested. + """ + root, parts = _match_allowed_root(Path(path), allowed_roots, path) + if not _descriptor_relative_access_available(): + return _open_file_without_dir_fd( + path, + root, + parts, + allowed_roots, + access=access, + create_parents=create_parents, + ) + + directory_flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_CLOEXEC", 0) + ) + try: + directory_fd = os.open(root, directory_flags) + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + f"Access denied: restricted root changed or is a symbolic link: {root}." + ) from exc + raise + + try: + for component in parts[:-1]: + next_directory_fd = _open_nofollow_directory( + component, + directory_fd, + directory_flags, + path=path, + create=create_parents, + ) + os.close(directory_fd) + directory_fd = next_directory_fd + + file_flags = _access_file_flags(access) + file_flags |= ( + getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + file_fd = _open_nofollow_file( + parts[-1], + directory_fd, + file_flags, + path=path, + create=create_parents, + ) + try: + _validate_opened_file(file_fd, path) + return file_fd + except BaseException: + os.close(file_fd) + raise + finally: + os.close(directory_fd) diff --git a/astrbot/core/computer/process_sandbox/__init__.py b/astrbot/core/computer/process_sandbox/__init__.py new file mode 100644 index 0000000000..a0a7693d71 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/__init__.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import platform +import shutil +import sys +import tempfile +from pathlib import Path + +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + +from .base import ( + ProcessSandbox, + SandboxLimits, + SandboxProcess, + SandboxRunResult, + SandboxSpec, + SandboxTimeoutError, +) + + +def create_process_sandbox() -> ProcessSandbox: + """Select the restricted-process launcher for the current system. + + Returns: + Bubblewrap on Linux or Seatbelt on macOS. + + Raises: + RuntimeError: If the current system has no Local sandbox implementation + or the required launcher is missing. + """ + if sys.platform.startswith("linux"): + if not shutil.which("bwrap"): + raise RuntimeError( + "bubblewrap (`bwrap`) is required for restricted Local execution." + ) + from .bubblewrap import BubblewrapProcessSandbox + + return BubblewrapProcessSandbox() + if sys.platform == "darwin": + if shutil.which("sandbox-exec", path="/usr/bin") != "/usr/bin/sandbox-exec": + raise RuntimeError( + "Seatbelt (`/usr/bin/sandbox-exec`) is required for restricted " + "Local execution." + ) + from .seatbelt import SeatbeltProcessSandbox + + return SeatbeltProcessSandbox() + raise RuntimeError("No Local process sandbox backend is available.") + + +def detect_local_runtime_info(*, probe: bool = False) -> dict: + """Probe OS, architecture, and Local sandbox availability once. + + Args: + probe: When True, launch a 5s sandbox no-op if the backend is present. + Config saves should leave this false; the Dashboard version snapshot + can afford the extra process. + + Returns: + Runtime snapshot consumed by the Dashboard version API and Local + permission validation. Restart AstrBot after installing sandbox + dependencies to refresh it. + """ + system = platform.system().lower() + sandbox: dict = {"backend": None, "status": "unsupported"} + if system == "linux": + sandbox = { + "backend": "bubblewrap", + "status": "detected" if shutil.which("bwrap") else "missing", + } + elif system == "darwin": + sandbox = { + "backend": "seatbelt", + "status": ( + "detected" + if shutil.which("sandbox-exec", path="/usr/bin") + == "/usr/bin/sandbox-exec" + else "missing" + ), + } + if probe and sandbox["status"] == "detected": + try: + temp_root = Path(get_astrbot_temp_path()) + temp_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="sandbox-probe-", dir=temp_root + ) as workspace: + result = create_process_sandbox().run( + ["/bin/sh", "-c", ":"], + SandboxSpec(workspace=Path(workspace)), + timeout=5, + output_limit=1024, + ) + if result.returncode != 0: + raise RuntimeError( + result.stderr.decode("utf-8", errors="replace").strip() + or f"Sandbox probe exited with code {result.returncode}." + ) + except (OSError, RuntimeError) as exc: + sandbox.update( + status="unavailable", error=str(exc)[:1024] or type(exc).__name__ + ) + return { + "os": system, + "arch": platform.machine(), + "sandbox": sandbox, + } + + +__all__ = ( + "ProcessSandbox", + "SandboxLimits", + "SandboxProcess", + "SandboxRunResult", + "SandboxSpec", + "SandboxTimeoutError", + "create_process_sandbox", + "detect_local_runtime_info", +) diff --git a/astrbot/core/computer/process_sandbox/base.py b/astrbot/core/computer/process_sandbox/base.py new file mode 100644 index 0000000000..f84dc4cf8f --- /dev/null +++ b/astrbot/core/computer/process_sandbox/base.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class SandboxLimits: + """Resource ceilings applied to a sandboxed process tree. + + Args: + cpu_seconds: Maximum CPU time in seconds. + file_size_bytes: Maximum size of a file created by one process. + memory_bytes: Maximum address space or job memory in bytes. + open_files: Maximum number of open file descriptors or handles when + supported by the platform. + processes: Maximum number of processes in the sandbox. + """ + + cpu_seconds: int = 300 + file_size_bytes: int = 512 * 1024 * 1024 + memory_bytes: int = 8 * 1024 * 1024 * 1024 + open_files: int = 256 + processes: int = 256 + + def __post_init__(self) -> None: + """Validate that every resource ceiling is a positive integer. + + Raises: + ValueError: If a resource ceiling is not a positive integer. + """ + for name in ( + "cpu_seconds", + "file_size_bytes", + "memory_bytes", + "open_files", + "processes", + ): + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"Sandbox limit `{name}` must be a positive integer.") + + +@dataclass(frozen=True, slots=True) +class SandboxSpec: + """Permissions, workspace, and limits for a sandboxed process. + + Args: + workspace: Directory exposed as the process working directory. + workspace_writable: Whether the process may modify the workspace. + allow_network: Whether the process may access the network. + filesystem_scope: Whether the process sees only its workspace or the + host filesystem. + limits: Resource ceilings enforced by the platform backend. + readable_roots: Additional directories that may be read in workspace scope. + writable_roots: Additional directories that may be read and modified in + workspace scope. Missing writable directories are created before launch. + """ + + workspace: Path + workspace_writable: bool = True + allow_network: bool = False + filesystem_scope: str = "workspace" + limits: SandboxLimits = field(default_factory=SandboxLimits) + readable_roots: tuple[Path, ...] = () + writable_roots: tuple[Path, ...] = () + + +@dataclass(frozen=True, slots=True) +class SandboxRunResult: + """Result returned by a synchronous sandbox execution. + + Args: + returncode: Process exit status. + stdout: Captured standard output. + stderr: Captured standard error. + stdout_limited: Whether standard output exceeded the requested limit. + stderr_limited: Whether standard error exceeded the requested limit. + """ + + returncode: int + stdout: bytes = b"" + stderr: bytes = b"" + stdout_limited: bool = False + stderr_limited: bool = False + + +class SandboxTimeoutError(TimeoutError): + """Raised when a sandbox process exceeds its execution timeout.""" + + +class SandboxStdin(Protocol): + """Writable stream used by managed sandbox processes.""" + + def write(self, data: bytes) -> None: + """Buffer bytes for the process standard input.""" + raise NotImplementedError + + async def drain(self) -> None: + """Flush buffered bytes without blocking the event loop.""" + raise NotImplementedError + + +class SandboxStdout(Protocol): + """Readable stream used by managed sandbox processes.""" + + async def read(self, n: int = -1) -> bytes: + """Read up to ``n`` bytes from the process standard output.""" + raise NotImplementedError + + +class SandboxProcess(Protocol): + """Process operations used by managed Local shell sessions.""" + + @property + def pid(self) -> int: + """Return the process identifier.""" + raise NotImplementedError + + @property + def returncode(self) -> int | None: + """Return the exit status, or ``None`` while the process is running.""" + raise NotImplementedError + + @property + def stdin(self) -> SandboxStdin | None: + """Return the process standard-input stream when configured.""" + raise NotImplementedError + + @property + def stdout(self) -> SandboxStdout | None: + """Return the process standard-output stream when configured.""" + raise NotImplementedError + + async def wait(self) -> int: + """Wait for the process to exit.""" + raise NotImplementedError + + def interrupt(self) -> None: + """Interrupt the sandbox process tree.""" + raise NotImplementedError + + def terminate(self) -> None: + """Request graceful termination of the sandbox process tree.""" + raise NotImplementedError + + def kill(self) -> None: + """Force termination of the sandbox process tree.""" + raise NotImplementedError + + +class ProcessSandbox(ABC): + """Platform-independent launcher for restricted child processes.""" + + def _prepare_command( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> tuple[list[str], Path, dict[str, str]]: + """Validate and normalize a command before platform-specific launch. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem and network access granted to the process. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Normalized arguments, workspace, and environment values. + + Raises: + RuntimeError: If the workspace does not exist. + ValueError: If the command, scope, or environment is invalid. + """ + if not argv: + raise ValueError("A sandbox command is required.") + if spec.filesystem_scope not in {"workspace", "host"}: + raise ValueError( + f"Invalid Local filesystem scope: {spec.filesystem_scope}." + ) + + sandbox_argv = list(argv) + workspace = spec.workspace.resolve() + if not workspace.is_dir(): + raise RuntimeError(f"Sandbox workspace does not exist: {workspace}") + if spec.filesystem_scope == "workspace": + for root in spec.writable_roots: + root.mkdir(parents=True, exist_ok=True) + + normalized_env: dict[str, str] = {} + for raw_key, raw_value in (env or {}).items(): + key = str(raw_key) + value = str(raw_value) + if not key or "=" in key or "\x00" in key or "\x00" in value: + raise ValueError(f"Invalid sandbox environment variable name: {key!r}.") + normalized_env[key] = value + + return sandbox_argv, workspace, normalized_env + + @abstractmethod + def run( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + timeout: float | None = None, + output_limit: int | None = None, + discard_stdout: bool = False, + ) -> SandboxRunResult: + """Run a restricted process synchronously. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem and network access granted to the process. + env: Additional environment variables exposed inside the sandbox. + timeout: Maximum wall-clock runtime in seconds. + output_limit: Maximum captured bytes for each output stream. + discard_stdout: Whether to discard standard output. + + Returns: + Platform-independent process result. + + Raises: + SandboxTimeoutError: If the process exceeds ``timeout``. + """ + raise NotImplementedError + + @abstractmethod + async def spawn_shell( + self, + command: str, + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> SandboxProcess: + """Start a managed shell command asynchronously. + + Args: + command: Shell command to execute inside the sandbox. + spec: Filesystem and network access granted to the process. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Running restricted process. + """ + raise NotImplementedError diff --git a/astrbot/core/computer/process_sandbox/bubblewrap.py b/astrbot/core/computer/process_sandbox/bubblewrap.py new file mode 100644 index 0000000000..a72cabc7ad --- /dev/null +++ b/astrbot/core/computer/process_sandbox/bubblewrap.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from .base import SandboxSpec +from .unix import UnixProcessSandbox, build_resource_limited_argv + +_TMP_BYTES = 256 * 1024 * 1024 +_NETWORK_CONFIG_PATHS = { + Path("/etc/resolv.conf"), + Path("/etc/hosts"), + Path("/etc/host.conf"), + Path("/etc/gai.conf"), +} + + +class BubblewrapProcessSandbox(UnixProcessSandbox): + """Linux restricted-process launcher backed by bubblewrap.""" + + def _build_command( + self, + argv: list[str], + workspace: Path, + spec: SandboxSpec, + env: dict[str, str], + ) -> list[str]: + """Build the bubblewrap command for validated inputs.""" + bwrap_path = shutil.which("bwrap") + if not bwrap_path: + raise RuntimeError( + "bubblewrap (`bwrap`) is required for restricted Local execution." + ) + if not Path("/bin/sh").exists(): + raise RuntimeError("The Local bubblewrap sandbox requires /bin/sh.") + + executable_path = ( + Path(argv[0]).resolve() + if Path(argv[0]).is_absolute() and Path(argv[0]).exists() + else Path(sys.executable).resolve() + ) + command = [ + bwrap_path, + "--unshare-all", + "--new-session", + "--die-with-parent", + "--clearenv", + ] + if spec.allow_network: + command.append("--share-net") + + if spec.filesystem_scope == "host": + command.extend( + ( + "--bind", + "/", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + "--chdir", + str(workspace), + ) + ) + else: + command.extend( + ( + "--dir", + "/tmp", # nosec B108 + "--size", + str(_TMP_BYTES), + "--tmpfs", + "/tmp", # nosec B108 + ) + ) + + readonly_paths = { + Path("/usr"), + Path("/bin"), + Path("/sbin"), + Path("/lib"), + Path("/lib64"), + Path("/etc/alternatives"), + Path("/etc/ld.so.cache"), + Path("/etc/ld.so.conf"), + Path("/etc/ld.so.conf.d"), + Path("/etc/localtime"), + Path("/etc/nsswitch.conf"), + Path("/etc/passwd"), + Path("/etc/group"), + Path(sys.prefix).resolve(), + Path(sys.base_prefix).resolve(), + } + if spec.filesystem_scope == "workspace": + if spec.allow_network: + readonly_paths.update(_NETWORK_CONFIG_PATHS) + readonly_paths.update(root.resolve() for root in spec.readable_roots) + writable_paths = {root.resolve() for root in spec.writable_roots} + if spec.workspace_writable: + writable_paths.add(workspace) + else: + readonly_paths.add(workspace) + readonly_paths.difference_update(writable_paths) + if not any( + executable_path == path or executable_path.is_relative_to(path) + for path in readonly_paths + ): + readonly_paths.add(executable_path) + # Keep the venv entry point usable when uv links its interpreter + # through a directory alias outside the mounted Python prefixes. + pending = [Path(sys.executable)] + seen_links: set[Path] = set() + while pending: + path = pending.pop() + for link in (path, *path.parents): + if link in seen_links or not link.is_symlink(): + continue + seen_links.add(link) + target = link.parent / link.readlink() / path.relative_to(link) + pending.append(Path(os.path.abspath(target))) + if not any(link.is_relative_to(root) for root in readonly_paths): + readonly_paths.add(link) + readonly_paths = {path for path in readonly_paths if path.exists()} + + required_directories = { + Path("/tmp"), # nosec B108 + Path("/tmp/home"), # nosec B108 + } + for path in (*readonly_paths, *writable_paths): + required_directories.update( + parent + for parent in path.parents + if parent != Path("/") and parent not in readonly_paths + ) + for directory in sorted( + required_directories, + key=lambda path: len(path.parts), + ): + if directory != Path("/tmp"): # nosec B108 + command.extend(("--dir", str(directory))) + command.extend(("--proc", "/proc", "--dev", "/dev")) + + for path in sorted(readonly_paths, key=lambda item: len(item.parts)): + # Resolver files often link into /run. Bind their contents without + # exposing the rest of the host service's runtime directory. + if path.is_symlink() and path not in _NETWORK_CONFIG_PATHS: + command.extend(("--symlink", os.readlink(path), str(path))) + else: + command.extend(("--ro-bind", str(path), str(path))) + for path in sorted(writable_paths, key=lambda item: len(item.parts)): + command.extend(("--bind", str(path), str(path))) + # A writable workspace or attachment root must not make AstrBot's + # Python installation writable when it contains that installation. + for path in sorted( + {Path(sys.prefix).resolve(), Path(sys.base_prefix).resolve()}, + key=lambda item: len(item.parts), + ): + if any( + path.is_relative_to(root) or root.is_relative_to(path) + for root in writable_paths + ): + command.extend(("--ro-bind", str(path), str(path))) + command.extend(("--chdir", str(workspace))) + + for key, value in sorted(env.items()): + command.extend(("--setenv", key, value)) + command.extend( + ( + "--setenv", + "PATH", + f"{Path(sys.executable).parent}:/usr/local/bin:/usr/bin:/bin", + "--setenv", + "HOME", + str(workspace) if spec.filesystem_scope == "host" else "/tmp/home", # nosec B108 + "--setenv", + "TMPDIR", + "/tmp", # nosec B108 + "--setenv", + "LANG", + "C.UTF-8", + "--", + *build_resource_limited_argv(argv, spec.limits), + ) + ) + return command diff --git a/astrbot/core/computer/process_sandbox/seatbelt.py b/astrbot/core/computer/process_sandbox/seatbelt.py new file mode 100644 index 0000000000..faf22af8e0 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/seatbelt.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from .base import SandboxSpec +from .unix import UnixProcessSandbox, build_resource_limited_argv + +_PROFILE = """ +(version 1) +(deny default) +(deny mach-priv-host-port) +(import "system.sb") + +(allow process-fork) +(allow process-exec) +(allow process-info* (target self)) +(deny process-exec + (literal "/usr/bin/open") + (literal "/usr/bin/osascript")) +(deny appleevent-send) +(deny mach-lookup + (global-name "com.apple.coreservices.launchservicesd") + (global-name "com.apple.lsd.mapdb") + (global-name "com.apple.lsd.modifydb") + (global-name "com.apple.lsd.open") + (global-name "com.apple.lsd.xpc")) + +(allow file-read-metadata file-test-existence) +(allow file-read* file-test-existence + (subpath "/bin") + (subpath "/usr/bin") + (subpath "/usr/libexec") + (literal (param "EXECUTABLE")) + (subpath (param "WORKSPACE")) + (subpath (param "PYTHON_PREFIX")) + (subpath (param "PYTHON_BASE_PREFIX"))) +(allow file-map-executable + (subpath "/bin") + (subpath "/usr/bin") + (subpath "/usr/libexec") + (literal (param "EXECUTABLE")) + (subpath (param "WORKSPACE")) + (subpath (param "PYTHON_PREFIX")) + (subpath (param "PYTHON_BASE_PREFIX"))) +(allow file-write* + (subpath (param "WORKSPACE"))) + +(deny file-read* + (literal "/private/etc/master.passwd") + (literal "/private/etc/passwd")) +(deny network*) +""" +_READ_ONLY_PROFILE = _PROFILE.replace( + '(allow file-write*\n (subpath (param "WORKSPACE")))', + "(deny file-write*)", +) + + +class SeatbeltProcessSandbox(UnixProcessSandbox): + """macOS restricted-process launcher backed by Seatbelt.""" + + def _build_command( + self, + argv: list[str], + workspace: Path, + spec: SandboxSpec, + env: dict[str, str], + ) -> list[str]: + """Build the Seatbelt command for validated inputs.""" + seatbelt_path = shutil.which("sandbox-exec", path="/usr/bin") + if seatbelt_path != "/usr/bin/sandbox-exec": + raise RuntimeError( + "Seatbelt (`/usr/bin/sandbox-exec`) is required for restricted " + "Local execution on macOS." + ) + + executable_path = ( + Path(argv[0]).resolve() + if Path(argv[0]).is_absolute() and Path(argv[0]).exists() + else Path(sys.executable).resolve() + ) + profile = _PROFILE if spec.workspace_writable else _READ_ONLY_PROFILE + if spec.filesystem_scope == "host": + profile = profile.replace( + '(import "system.sb")', + '(import "system.sb")\n\n' + "(allow file-read* file-write* file-test-existence " + "file-read-metadata file-map-executable)", + ).replace( + "(deny file-read*\n" + ' (literal "/private/etc/master.passwd")\n' + ' (literal "/private/etc/passwd"))\n', + "", + ) + if spec.allow_network: + profile = profile.replace("(deny network*)", "(allow network*)") + + root_definitions: list[str] = [] + if spec.filesystem_scope == "workspace": + writable_roots = {root.resolve() for root in spec.writable_roots} + readable_roots = { + root.resolve() + for root in (*spec.readable_roots, *spec.writable_roots) + if root.is_dir() + } + for index, root in enumerate(sorted(readable_roots)): + parameter = f"ALLOWED_ROOT_{index}" + root_definitions.extend(("-D", f"{parameter}={root}")) + operations = "file-read* file-map-executable" + if root in writable_roots: + operations += " file-write*" + profile += f'\n(allow {operations} (subpath (param "{parameter}")))\n' + # Explicit denial also protects Python inside an otherwise writable root. + profile += ( + '\n(deny file-write* (subpath (param "PYTHON_PREFIX")) ' + '(subpath (param "PYTHON_BASE_PREFIX")))\n' + ) + + executable_definitions: list[str] = [] + executable_rules: list[str] = [] + for index, read_path in enumerate(self._executable_read_paths(executable_path)): + parameter = f"EXECUTABLE_{index}" + executable_definitions.extend(("-D", f"{parameter}={read_path}")) + executable_rules.append(f'(literal (param "{parameter}"))') + profile = profile.replace( + '(literal (param "EXECUTABLE"))', + "\n ".join(executable_rules), + ) + + environment = [ + *(f"{key}={value}" for key, value in sorted(env.items())), + f"PATH={Path(sys.executable).parent}:/usr/bin:/bin", + f"HOME={workspace}", + f"TMPDIR={workspace}", + "LANG=C.UTF-8", + ] + return [ + seatbelt_path, + "-D", + f"WORKSPACE={workspace}", + *root_definitions, + *executable_definitions, + "-D", + f"PYTHON_PREFIX={Path(sys.prefix).resolve()}", + "-D", + f"PYTHON_BASE_PREFIX={Path(sys.base_prefix).resolve()}", + "-p", + profile, + "/usr/bin/env", + "-i", + *environment, + *build_resource_limited_argv(argv, spec.limits), + ] + + def _executable_read_paths(self, executable_path: Path) -> tuple[Path, ...]: + """Collect executable and dynamic-library paths needed by Seatbelt. + + Args: + executable_path: Executable launched inside Seatbelt. + + Returns: + Existing absolute files that the dynamic loader may need to read. + """ + read_paths = {executable_path, executable_path.resolve()} + resolved_executable = executable_path.resolve() + if any( + resolved_executable.is_relative_to(root) + for root in ( + Path("/bin"), + Path("/usr"), + Path(sys.prefix).resolve(), + Path(sys.base_prefix).resolve(), + ) + ): + return tuple(sorted(read_paths, key=str)) + + pending = [resolved_executable] + inspected: set[Path] = set() + while pending and len(inspected) < 64: + current = pending.pop() + if current in inspected: + continue + inspected.add(current) + try: + result = subprocess.run( + ["/usr/bin/otool", "-L", str(current)], + capture_output=True, + check=False, + timeout=5, + ) + except OSError, subprocess.TimeoutExpired: + continue + if result.returncode != 0: + continue + for line in result.stdout.decode("utf-8", errors="replace").splitlines()[ + 1: + ]: + dependency_text = line.strip().split(" (", 1)[0] + if not dependency_text.startswith("/"): + continue + dependency = Path(dependency_text) + if not dependency.exists(): + continue + resolved_dependency = dependency.resolve() + read_paths.update((dependency, resolved_dependency)) + if not ( + resolved_dependency.is_relative_to("/usr") + or resolved_dependency.is_relative_to("/System") + ): + pending.append(resolved_dependency) + return tuple(sorted(read_paths, key=str)) diff --git a/astrbot/core/computer/process_sandbox/unix.py b/astrbot/core/computer/process_sandbox/unix.py new file mode 100644 index 0000000000..a483f23904 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/unix.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import asyncio +import os +import signal as signal_module +import subprocess +import sys +import tempfile +from abc import abstractmethod +from pathlib import Path + +from .base import ( + ProcessSandbox, + SandboxLimits, + SandboxProcess, + SandboxRunResult, + SandboxSpec, + SandboxStdin, + SandboxStdout, + SandboxTimeoutError, +) + + +class UnixSandboxProcess: + """Adapt an asyncio process to process-tree sandbox semantics.""" + + def __init__(self, process: asyncio.subprocess.Process) -> None: + """Store the session-leading asyncio process. + + Args: + process: Process started in a new Unix session. + """ + self._process = process + + @property + def pid(self) -> int: + """Return the process-group leader identifier.""" + return self._process.pid + + @property + def returncode(self) -> int | None: + """Return the process exit status when available.""" + return self._process.returncode + + @property + def stdin(self) -> SandboxStdin | None: + """Return the native asyncio standard-input stream.""" + return self._process.stdin + + @property + def stdout(self) -> SandboxStdout | None: + """Return the native asyncio standard-output stream.""" + return self._process.stdout + + async def wait(self) -> int: + """Wait for the session-leading process to exit.""" + return await self._process.wait() + + def interrupt(self) -> None: + """Send SIGINT to the complete Unix process group.""" + self._send_signal(signal_module.SIGINT) + + def terminate(self) -> None: + """Send SIGTERM to the complete Unix process group.""" + self._send_signal(signal_module.SIGTERM) + + def kill(self) -> None: + """Send SIGKILL to the complete Unix process group.""" + self._send_signal(signal_module.SIGKILL) + + def _send_signal(self, signal: int) -> None: + """Send a Unix signal to the process group if it still exists. + + Args: + signal: Unix signal number to send. + """ + if self._process.returncode is not None: + return + try: + os.killpg(self.pid, signal) + except ProcessLookupError, PermissionError: + return + + +class UnixProcessSandbox(ProcessSandbox): + """Common launcher behavior for Unix sandbox implementations.""" + + def build_command( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> list[str]: + """Build a Unix sandbox wrapper command. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem, network, and resource policy. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Platform sandbox command and arguments. + """ + argv, workspace, env = self._prepare_command(argv, spec, env=env) + return self._build_command(argv, workspace, spec, env) + + def run( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + timeout: float | None = None, + output_limit: int | None = None, + discard_stdout: bool = False, + ) -> SandboxRunResult: + """Run a command through the Unix sandbox wrapper. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem, network, and resource policy. + env: Additional environment variables exposed inside the sandbox. + timeout: Maximum wall-clock runtime in seconds. + output_limit: Maximum captured bytes for each output stream. + discard_stdout: Whether to discard standard output. + + Returns: + Captured process result. + + Raises: + SandboxTimeoutError: If the process exceeds ``timeout``. + ValueError: If ``output_limit`` is not positive. + """ + if output_limit is not None and output_limit <= 0: + raise ValueError("Sandbox output limit must be greater than 0.") + + with ( + tempfile.TemporaryFile() as stdout_file, + tempfile.TemporaryFile() as stderr_file, + ): + try: + result = subprocess.run( + self.build_command(argv, spec, env=env), + cwd=spec.workspace.resolve(), + env={"PATH": os.defpath}, + timeout=timeout, + stdout=subprocess.DEVNULL if discard_stdout else stdout_file, + stderr=stderr_file, + ) + except subprocess.TimeoutExpired as exc: + raise SandboxTimeoutError( + f"Sandbox command timed out after {timeout} seconds." + ) from exc + + read_size = None if output_limit is None else output_limit + 1 + if discard_stdout: + stdout = b"" + else: + stdout_file.seek(0) + stdout = stdout_file.read(read_size) + stderr_file.seek(0) + stderr = stderr_file.read(read_size) + return SandboxRunResult( + returncode=result.returncode, + stdout=stdout[:output_limit] if output_limit is not None else stdout, + stderr=stderr[:output_limit] if output_limit is not None else stderr, + stdout_limited=output_limit is not None and len(stdout) > output_limit, + stderr_limited=output_limit is not None and len(stderr) > output_limit, + ) + + async def spawn_shell( + self, + command: str, + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> SandboxProcess: + """Start a shell command in a separately managed process group. + + Args: + command: Shell command to execute inside the sandbox. + spec: Filesystem, network, and resource policy. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Process adapter whose lifecycle methods affect the process group. + """ + process = await asyncio.create_subprocess_exec( + *self.build_command(["/bin/sh", "-c", command], spec, env=env), + cwd=spec.workspace.resolve(), + env={"PATH": os.defpath}, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, + ) + return UnixSandboxProcess(process) + + @abstractmethod + def _build_command( + self, + argv: list[str], + workspace: Path, + spec: SandboxSpec, + env: dict[str, str], + ) -> list[str]: + """Build a Unix sandbox command after common input validation.""" + + +def build_resource_limited_argv( + argv: list[str], + limits: SandboxLimits, +) -> list[str]: + """Wrap a command with Unix resource-limit setup. + + Args: + argv: Command and arguments to execute after applying limits. + limits: Resource ceilings requested by the common sandbox policy. + + Returns: + Python command that applies supported Unix limits and then executes + ``argv``. + """ + wrapper_code = f""" +import os +import resource +import sys + +limits = [ + (resource.RLIMIT_CPU, {limits.cpu_seconds}), + (resource.RLIMIT_FSIZE, {limits.file_size_bytes}), + (resource.RLIMIT_NOFILE, {limits.open_files}), + (resource.RLIMIT_CORE, 0), +] +if sys.platform.startswith("linux"): + # macOS RLIMIT_NPROC counts every process owned by the host user, and its + # Python process starts above this virtual-address limit. + limits.extend( + ( + (resource.RLIMIT_NPROC, {limits.processes}), + (resource.RLIMIT_AS, {limits.memory_bytes}), + ) + ) +for kind, requested in limits: + _, hard = resource.getrlimit(kind) + value = requested if hard == resource.RLIM_INFINITY else min(requested, hard) + resource.setrlimit(kind, (value, value)) +os.execvpe(sys.argv[1], sys.argv[1:], os.environ) +""" + return [ + str(Path(sys.executable).resolve()), + "-I", + "-S", + "-c", + wrapper_code, + *argv, + ] diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index a458c52d86..2f1c0b8016 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1,6 +1,7 @@ """如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。""" import os +import platform from astrbot import __version__ from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG @@ -8,6 +9,32 @@ from .agent_runner import get_agent_runner_config_default + +def get_local_permission_defaults(system: str | None = None) -> dict: + """Return fresh Local permission defaults for the operating system. + + Args: + system: Operating system name, or None to use the current system. + + Returns: + Per-role policies. Windows disables member access and gives admins + unrestricted access because workspace isolation is unavailable. + """ + windows = (system or platform.system()).lower() == "windows" + return { + "member": { + "allow_execution": False, + "allow_network": False, + "filesystem_scope": "none" if windows else "workspace", + }, + "admin": { + "allow_execution": True, + "allow_network": True, + "filesystem_scope": "host" if windows else "workspace", + }, + } + + VERSION = __version__ DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") @@ -150,6 +177,7 @@ "add_cron_tools": True, }, "computer_use_runtime": "none", + "computer_use_local_permissions": get_local_permission_defaults(), "sandbox": { "booter": "shipyard_neo", "shipyard_neo_endpoint": "", @@ -1354,7 +1382,7 @@ "timeout": 120, "proxy_mode": "inherit", "proxy_url": "", - "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "custom_headers": {}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "OpenCode Go Chat Completions": { @@ -1446,7 +1474,7 @@ "timeout": 120, "proxy_mode": "inherit", "proxy_url": "", - "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "custom_headers": {}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "Xiaomi": { @@ -1473,7 +1501,7 @@ "timeout": 120, "proxy_mode": "inherit", "proxy_url": "", - "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "custom_headers": {}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "xAI": { @@ -3899,9 +3927,46 @@ "description": "Computer Use Runtime", "type": "string", "options": ["none", "local", "sandbox"], - "labels": ["无", "本地", "沙箱"], + "labels": [ + "不允许任何环境", + "本机环境", + "第三方沙箱环境", + ], "hint": "选择 Computer Use 运行环境。", }, + "provider_settings.computer_use_local_permissions": { + "description": "本地权限策略", + "type": "object", + "_special": "local_permission_matrix", + "full_width": True, + "items": { + "member": { + "type": "object", + "items": { + "allow_execution": {"type": "bool"}, + "allow_network": {"type": "bool"}, + "filesystem_scope": { + "type": "string", + "options": ["none", "workspace", "host"], + }, + }, + }, + "admin": { + "type": "object", + "items": { + "allow_execution": {"type": "bool"}, + "allow_network": {"type": "bool"}, + "filesystem_scope": { + "type": "string", + "options": ["none", "workspace", "host"], + }, + }, + }, + }, + "condition": { + "provider_settings.computer_use_runtime": "local", + }, + }, "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "docs": "use/astrbot-agent-sandbox.html", @@ -4212,7 +4277,7 @@ "provider_settings.image_compress_options.max_size": { "description": "最大边长", "type": "int", - "hint": "压缩后图片的最长边,单位为像素。仅缩小、从不放大。", + "hint": "压缩后图片的最长边,单位为像素。仅缩小、从不放大。CUA 沙箱下输入图片不缩放,以保持像素坐标 1:1;大图可能超出服务商上传限制。", "condition": { "provider_settings.image_compress_enabled": True, }, diff --git a/astrbot/core/execution_context.py b/astrbot/core/execution_context.py index c83f3c654b..b66a96cedd 100644 --- a/astrbot/core/execution_context.py +++ b/astrbot/core/execution_context.py @@ -348,14 +348,14 @@ async def llm_generate( if not prov or not isinstance(prov, Provider): raise ProviderNotFoundError(f"Provider {chat_provider_id} not found") from astrbot.core.agent.request_preparation import ( + cua_pixel_mode_from_settings, image_compress_args_from_settings, prepare_provider_request, ) + provider_settings = (self.get_config() or {}).get("provider_settings") compress_enabled, image_max_size, image_quality = ( - image_compress_args_from_settings( - (self.get_config() or {}).get("provider_settings") - ) + image_compress_args_from_settings(provider_settings) ) request = await prepare_provider_request( ProviderRequest( @@ -373,6 +373,7 @@ async def llm_generate( image_compress_enabled=compress_enabled, image_max_size=image_max_size, image_quality=image_quality, + warn_unresized_images=cua_pixel_mode_from_settings(provider_settings), ) llm_resp = await prov.text_chat( prompt=request.prompt, @@ -431,6 +432,7 @@ async def tool_loop_agent( """ # Import here to avoid circular imports from astrbot.core.agent.request_preparation import ( + cua_pixel_mode_from_settings, image_compress_args_from_settings, prepare_provider_request, ) @@ -486,6 +488,7 @@ async def tool_loop_agent( image_compress_enabled=compress_enabled, image_max_size=image_max_size, image_quality=image_quality, + warn_unresized_images=cua_pixel_mode_from_settings(provider_settings), ) if agent_context is None: agent_context = AstrAgentContext( diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 7d68c16eb2..da15e9add8 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -399,6 +399,8 @@ class LogManager: "asyncio": logging.WARNING, "tzlocal": logging.WARNING, "apscheduler": logging.WARNING, + "httpx": logging.WARNING, + "httpcore": logging.WARNING, } @classmethod diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index d5737698e3..6f7093c286 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -17,6 +17,7 @@ dump_messages_with_checkpoints, ) from astrbot.core.agent.request_preparation import ( + cua_pixel_mode_from_settings, image_compress_args_from_settings, prepare_provider_request, ) @@ -240,6 +241,9 @@ async def _refresh_prepared_request( image_compress_enabled=enabled, image_max_size=max_size, image_quality=quality, + warn_unresized_images=cua_pixel_mode_from_settings( + settings if isinstance(settings, dict) else None + ), ) req.image_urls = prepared.image_urls req.audio_urls = prepared.audio_urls diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 0a10256389..7d70db1ae4 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -241,6 +241,27 @@ async def send(self, message: MessageChain) -> PlatformSendResult | None: self.send_buffer = message return await self._post_send() + async def _close_stream_segment(self, stream_payload: dict): + """以 state=10 收尾当前流式段;流已开但 buffer 恰好为空时补最小收尾帧。 + + QQ C2C 流式协议缺 state=10 会在超时后把整段回滚到首包(#10066): + 中间分片已把全文发完、结尾没有剩余内容时也必须补一个 "\\n" 收尾帧, + 否则客户端等不到结束帧,最终只显示首包几个字。 + """ + stream_payload["state"] = 10 + has_content = self.send_buffer is not None and any( + (isinstance(c, Plain) and c.text) or not isinstance(c, Plain) + for c in self.send_buffer.chain + ) + if not has_content: + # 只有空 Plain 的 buffer 也算空:_post_send_one 会拒掉空文本, + # 收尾帧照样缺席(#10069 review) + if stream_payload.get("id") is None: + # 从未发出任何分片,无流可收 + return None + self.send_buffer = MessageChain(chain=[Plain(text="\n")]) + return await self._post_send(stream=stream_payload) + async def send_streaming(self, generator, use_fallback: bool = False): """流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送""" # 先标记事件层“已执行发送操作”,避免异常路径遗漏 @@ -267,9 +288,10 @@ async def send_streaming(self, generator, use_fallback: bool = False): # tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段 if chain.type == "break": - if self.send_buffer: - stream_payload["state"] = 10 - ret = await self._post_send(stream=stream_payload) + if (self.send_buffer and self.send_buffer.chain) or ( + stream_payload.get("id") is not None + ): + ret = await self._close_stream_segment(stream_payload) ret_id = self._extract_response_message_id(ret) if ret_id is not None: stream_payload["id"] = ret_id @@ -302,9 +324,8 @@ async def send_streaming(self, generator, use_fallback: bool = False): self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容 if isinstance(source, botpy.message.C2CMessage): - # 结束流式对话,发送 buffer 中剩余内容 - stream_payload["state"] = 10 - ret = await self._post_send(stream=stream_payload) + # 结束流式对话,发送 buffer 中剩余内容(空尾也要补收尾帧) + ret = await self._close_stream_segment(stream_payload) else: ret = await self._post_send() diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 28d1088ad2..c9f7a303d9 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -880,12 +880,19 @@ async def run(self) -> None: await asyncio.sleep(self._polling_restart_delay) def _on_polling_error(self, error: Exception) -> None: + # Non-network errors (e.g. Conflict when two bot instances poll the + # same token) have a clear cause; log a concise message instead of a + # full traceback to avoid filling the log. + if not isinstance(error, NetworkError): + logger.error( + f"Telegram polling request failed: {type(error).__name__}: {error!s}" + ) + return + logger.error( f"Telegram polling request failed: {type(error).__name__}: {error!s}", exc_info=error, ) - if not isinstance(error, NetworkError): - return if self._loop is None: return diff --git a/astrbot/core/provider/headers.py b/astrbot/core/provider/headers.py new file mode 100644 index 0000000000..54f971e47f --- /dev/null +++ b/astrbot/core/provider/headers.py @@ -0,0 +1,39 @@ +from astrbot import __version__ + +DEFAULT_USER_AGENT = f"astrbot/{__version__}" + + +def build_provider_headers(custom_headers: object = None) -> dict[str, str]: + """Build provider headers with an overridable AstrBot user agent. + + Args: + custom_headers: Optional header mapping from provider configuration. + + Returns: + A new header dictionary with string values and one User-Agent header. + """ + headers = {"User-Agent": DEFAULT_USER_AGENT} + if isinstance(custom_headers, dict): + for name, value in custom_headers.items(): + name, value = str(name), str(value) + if name.lower() == "user-agent": + if value.strip(): + headers["User-Agent"] = value + else: + headers[name] = value + return headers + + +def drop_sdk_user_agent(client: object) -> None: + """Drop the SDK's extra lowercase User-Agent when the client exposes it. + + Args: + client: A Gemini SDK client or test double. Missing private attributes + are ignored so constructor tests can stub the client. + """ + api_client = getattr(client, "_api_client", None) + http_options = getattr(api_client, "_http_options", None) + headers = getattr(http_options, "headers", None) + pop = getattr(headers, "pop", None) + if callable(pop): + pop("user-agent", None) diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index cffcd1ed6b..5f12812524 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -16,6 +16,7 @@ ProviderMeta, RerankResult, ) +from astrbot.core.provider.headers import build_provider_headers from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.error_redaction import safe_error @@ -36,6 +37,9 @@ def __init__(self, provider_config: dict) -> None: super().__init__() self.model_name = "" self.provider_config = provider_config + self.request_headers = build_provider_headers( + provider_config.get("custom_headers") + ) def set_model(self, model_name: str) -> None: """Set the current model name""" diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index b96640b1d3..f0da819211 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -25,6 +25,7 @@ log_connection_failure, ) +from ..headers import build_provider_headers from ..register import register_provider_adapter from .request_extra_headers import extra_headers_kwargs from .request_retry import retry_provider_request, retry_provider_request_context @@ -70,13 +71,15 @@ def _resolve_custom_headers( provider_config: dict, *, required_headers: dict[str, str] | None = None, - ) -> dict[str, str] | None: - merged_headers = cls._normalize_custom_headers(provider_config) or {} + ) -> dict[str, str]: + merged_headers = build_provider_headers( + cls._normalize_custom_headers(provider_config) + ) if required_headers: for header_name, header_value in required_headers.items(): if not merged_headers.get(header_name, "").strip(): merged_headers[header_name] = header_value - return merged_headers or None + return merged_headers def __init__( self, diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 1c1f9d2e67..7407daddc2 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -11,7 +11,7 @@ from httpx import AsyncClient, Timeout from astrbot import logger -from astrbot.core.config.default import VERSION +from astrbot.core.provider.headers import build_provider_headers from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.error_redaction import safe_error @@ -35,6 +35,7 @@ def _remove_incomplete_audio(file_path: Path) -> None: class OTTSProvider: def __init__(self, config: dict) -> None: + self.request_headers = build_provider_headers(config.get("custom_headers")) self.skey = config["OTTS_SKEY"] self.api_url = config["OTTS_URL"] self.auth_time_url = config["OTTS_AUTH_TIME"] @@ -60,7 +61,9 @@ async def __aenter__(self): if self._client is not None: await self.__aexit__(None, None, None) self._client = AsyncClient( - timeout=self.timeout, proxy=self.proxy if self.proxy else None + headers=self.request_headers, + timeout=self.timeout, + proxy=self.proxy if self.proxy else None, ) return self @@ -121,7 +124,7 @@ async def get_audio(self, text: str, voice_params: dict) -> str: "volume": voice_params["volume"], }, headers={ - "User-Agent": f"AstrBot/{VERSION}", + **self.request_headers, "UAK": "AstrBot/AzureTTS", }, ) @@ -207,7 +210,7 @@ async def __aenter__(self): await self.__aexit__(None, None, None) self._client = AsyncClient( headers={ - "User-Agent": f"AstrBot/{VERSION}", + **self.request_headers, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm", }, @@ -274,7 +277,7 @@ async def get_audio(self, text: str) -> str: content=ssml, headers={ "Authorization": f"Bearer {self.token}", - "User-Agent": f"AstrBot/{VERSION}", + **self.request_headers, }, ) response.raise_for_status() @@ -323,6 +326,7 @@ def _parse_provider( otts_config = json.loads(json_str) if not isinstance(otts_config, dict): raise ValueError("OTTS配置必须是JSON对象") + otts_config.setdefault("custom_headers", config.get("custom_headers")) required = {"OTTS_SKEY", "OTTS_URL", "OTTS_AUTH_TIME"} if missing := required - otts_config.keys(): raise ValueError(f"缺少OTTS参数: {', '.join(sorted(missing))}") diff --git a/astrbot/core/provider/sources/bailian_rerank_source.py b/astrbot/core/provider/sources/bailian_rerank_source.py index 39cc4bb251..80fc1bac0f 100644 --- a/astrbot/core/provider/sources/bailian_rerank_source.py +++ b/astrbot/core/provider/sources/bailian_rerank_source.py @@ -75,7 +75,8 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: } self.client = aiohttp.ClientSession( - headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout) + headers={**self.request_headers, **headers}, + timeout=aiohttp.ClientTimeout(total=self.timeout), ) # 设置模型名称 diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 3a4736bfe5..8b0ca601fe 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -83,6 +83,7 @@ def _call_qwen_tts(self, model: str, text: str): kwargs = { "model": model, + "headers": self.request_headers.copy(), "messages": None, "api_key": self.chosen_api_key, "voice": self.voice or "Cherry", @@ -133,7 +134,9 @@ async def _download_audio_from_url(self, url: str) -> bytes | None: timeout = max(self.timeout_ms / 1000, 1) if self.timeout_ms else 20 try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession( + headers={"User-Agent": self.request_headers["User-Agent"]} + ) as session, session.get( url, timeout=aiohttp.ClientTimeout(total=timeout), @@ -154,6 +157,9 @@ async def _synthesize_with_cosyvoice( text: str, ) -> tuple[bytes | None, str]: synthesizer = SpeechSynthesizer( + headers={ + name.lower(): value for name, value in self.request_headers.items() + }, model=model, voice=self.voice, format=AudioFormat.WAV_24000HZ_MONO_16BIT, diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 15fda1641c..f5ccbc342a 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -175,6 +175,16 @@ async def get_audio(self, text: str) -> str: if self.pitch: kwargs["pitch"] = self.pitch + from astrbot.core.provider.headers import DEFAULT_USER_AGENT + + try: + edge_tts_constants = importlib.import_module("edge_tts.constants") + edge_tts_constants.WSS_HEADERS["User-Agent"] = DEFAULT_USER_AGENT + except (ImportError, AttributeError, TypeError) as exc: + logger.debug( + "Edge TTS User-Agent override skipped: %s", + safe_error("", exc), + ) communicate = edge_tts_module.Communicate(proxy=self.proxy, **kwargs) await asyncio.wait_for( communicate.save(str(mp3_path)), timeout=self.timeout diff --git a/astrbot/core/provider/sources/elevenlabs_tts_source.py b/astrbot/core/provider/sources/elevenlabs_tts_source.py index c7be820ea9..2844514b10 100644 --- a/astrbot/core/provider/sources/elevenlabs_tts_source.py +++ b/astrbot/core/provider/sources/elevenlabs_tts_source.py @@ -125,6 +125,7 @@ def __init__( client = create_proxy_client( "ElevenLabs TTS", provider_config, + headers=self.request_headers, ) client.timeout = timeout self.client: httpx.AsyncClient | None = client diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 7c38fad3f4..d306bc8819 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -70,6 +70,7 @@ def __init__( self._route = resolve_proxy_route(local_config=provider_config) self.proxy = self._route.proxy_url or "" self.headers = { + **self.request_headers, "Authorization": f"Bearer {self.chosen_api_key}", } self.set_model(provider_config.get("model") or "s2-pro") diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py index f07f88ad94..b05f4a8290 100644 --- a/astrbot/core/provider/sources/gemini_embedding_source.py +++ b/astrbot/core/provider/sources/gemini_embedding_source.py @@ -9,6 +9,7 @@ from astrbot.core.utils.error_redaction import safe_error from ..entities import ProviderType +from ..headers import drop_sdk_user_agent from ..provider import EmbeddingProvider from ..register import register_provider_adapter @@ -30,7 +31,9 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: api_base: str = provider_config["embedding_api_base"] timeout: int = int(provider_config.get("timeout", 20)) - http_options = types.HttpOptions(timeout=timeout * 1000) + http_options = types.HttpOptions( + timeout=timeout * 1000, headers=self.request_headers + ) if api_base: api_base = api_base.removesuffix("/") http_options.base_url = api_base @@ -43,6 +46,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: http_options.async_client_args = httpx_client_kwargs(route) self.client = genai.Client(api_key=api_key, http_options=http_options).aio + drop_sdk_user_agent(self.client) self.model = provider_config.get( "embedding_model", diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 337fc832a6..d0ef2554e8 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -18,6 +18,7 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider.headers import drop_sdk_user_agent from astrbot.core.provider.provider import Provider from astrbot.core.utils.media_utils import ( describe_media_ref, @@ -84,6 +85,7 @@ def __init__( def _init_client(self) -> None: """初始化Gemini客户端""" http_options = types.HttpOptions( + headers=self.request_headers, base_url=self.api_base, timeout=self.timeout * 1000, # 毫秒 ) @@ -112,6 +114,7 @@ def _init_client(self) -> None: api_key=self.chosen_api_key, http_options=http_options, ).aio + drop_sdk_user_agent(self.client) def _init_safety_settings(self) -> None: """初始化安全设置""" diff --git a/astrbot/core/provider/sources/gemini_tts_source.py b/astrbot/core/provider/sources/gemini_tts_source.py index 031ae94bd0..2f66d0c05e 100644 --- a/astrbot/core/provider/sources/gemini_tts_source.py +++ b/astrbot/core/provider/sources/gemini_tts_source.py @@ -11,6 +11,7 @@ from astrbot.core.utils.error_redaction import safe_error from ..entities import ProviderType +from ..headers import drop_sdk_user_agent from ..provider import TTSProvider from ..register import register_provider_adapter @@ -32,7 +33,9 @@ def __init__( api_key: str = provider_config.get("gemini_tts_api_key", "") api_base: str | None = provider_config.get("gemini_tts_api_base") timeout: int = int(provider_config.get("gemini_tts_timeout", 20)) - http_options = types.HttpOptions(timeout=timeout * 1000) + http_options = types.HttpOptions( + timeout=timeout * 1000, headers=self.request_headers + ) if api_base: api_base = api_base.removesuffix("/") @@ -46,6 +49,7 @@ def __init__( http_options.async_client_args = httpx_client_kwargs(route) self.client = genai.Client(api_key=api_key, http_options=http_options).aio + drop_sdk_user_agent(self.client) self.model: str = provider_config.get( "gemini_tts_model", "gemini-2.5-flash-preview-tts", diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index a8930cfb18..d7eb4cdba9 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -47,6 +47,7 @@ def __init__( async def initialize(self) -> None: """异步初始化:在 ProviderManager 中被调用""" session = aiohttp.ClientSession( + headers=self.request_headers, timeout=aiohttp.ClientTimeout(total=self.timeout), ) self._session = session diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 7c3ae286f0..3d5c93d6ea 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -57,7 +57,7 @@ async def get_audio(self, text: str) -> str: completed = False try: path.parent.mkdir(parents=True, exist_ok=True) - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(headers=self.request_headers) as session: async with session.post(url, json=data, headers=headers) as response: if response.status != 200: logger.error( diff --git a/astrbot/core/provider/sources/kimi_code_source.py b/astrbot/core/provider/sources/kimi_code_source.py index 02c200271f..bac18ce190 100644 --- a/astrbot/core/provider/sources/kimi_code_source.py +++ b/astrbot/core/provider/sources/kimi_code_source.py @@ -1,9 +1,10 @@ +from ..headers import DEFAULT_USER_AGENT from ..register import register_provider_adapter from .anthropic_source import ProviderAnthropic KIMI_CODE_API_BASE = "https://api.kimi.com/coding" KIMI_CODE_DEFAULT_MODEL = "kimi-for-coding" -KIMI_CODE_USER_AGENT = "claude-code/0.1.0" +KIMI_CODE_USER_AGENT = DEFAULT_USER_AGENT @register_provider_adapter( diff --git a/astrbot/core/provider/sources/mimo_stt_api_source.py b/astrbot/core/provider/sources/mimo_stt_api_source.py index d5e94d5de6..ea2e28609c 100644 --- a/astrbot/core/provider/sources/mimo_stt_api_source.py +++ b/astrbot/core/provider/sources/mimo_stt_api_source.py @@ -89,7 +89,7 @@ async def get_text(self, audio_url: str) -> str: try: response = await self.client.post( build_api_url(self.api_base), - headers=build_headers(self.chosen_api_key), + headers={**self.request_headers, **build_headers(self.chosen_api_key)}, json=payload, ) try: diff --git a/astrbot/core/provider/sources/mimo_tts_api_source.py b/astrbot/core/provider/sources/mimo_tts_api_source.py index d9bb5dac43..f6f3b22442 100644 --- a/astrbot/core/provider/sources/mimo_tts_api_source.py +++ b/astrbot/core/provider/sources/mimo_tts_api_source.py @@ -112,7 +112,7 @@ async def get_audio(self, text: str) -> str: try: response = await self.client.post( build_api_url(self.api_base), - headers=build_headers(self.chosen_api_key), + headers={**self.request_headers, **build_headers(self.chosen_api_key)}, json=self._build_payload(text), ) diff --git a/astrbot/core/provider/sources/minimax_token_plan_source.py b/astrbot/core/provider/sources/minimax_token_plan_source.py index 8d86c77b73..b16c7e4dc2 100644 --- a/astrbot/core/provider/sources/minimax_token_plan_source.py +++ b/astrbot/core/provider/sources/minimax_token_plan_source.py @@ -47,7 +47,7 @@ async def get_models(self) -> list[str]: logger.warning("No API key configured for MiniMax Token Plan.") return [] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=self.request_headers) as client: resp = await client.get( "https://api.minimaxi.com/v1/models", headers={"Authorization": f"Bearer {key}"}, diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index 746446ce17..c563226b82 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -104,7 +104,7 @@ async def _call_tts_stream(self, text: str) -> AsyncIterator[str]: """进行流式请求""" try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession(headers=self.request_headers) as session, session.post( self.concat_base_url, headers=self.headers, diff --git a/astrbot/core/provider/sources/nvidia_embedding_source.py b/astrbot/core/provider/sources/nvidia_embedding_source.py index d5c47fcc43..cbf8d47fcb 100644 --- a/astrbot/core/provider/sources/nvidia_embedding_source.py +++ b/astrbot/core/provider/sources/nvidia_embedding_source.py @@ -55,7 +55,7 @@ async def _get_client(self): } timeout = aiohttp.ClientTimeout(total=self.timeout) self.client = aiohttp.ClientSession( - headers=headers, + headers={**self.request_headers, **headers}, timeout=timeout, trust_env=False, ) diff --git a/astrbot/core/provider/sources/nvidia_rerank_source.py b/astrbot/core/provider/sources/nvidia_rerank_source.py index d69cbc3532..03c4c88fbd 100644 --- a/astrbot/core/provider/sources/nvidia_rerank_source.py +++ b/astrbot/core/provider/sources/nvidia_rerank_source.py @@ -42,7 +42,8 @@ async def _get_client(self): "Accept": "application/json", } self.client = aiohttp.ClientSession( - headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout) + headers={**self.request_headers, **headers}, + timeout=aiohttp.ClientTimeout(total=self.timeout), ) return self.client diff --git a/astrbot/core/provider/sources/ollama_embedding_source.py b/astrbot/core/provider/sources/ollama_embedding_source.py index eb03441d71..cc22fe2194 100644 --- a/astrbot/core/provider/sources/ollama_embedding_source.py +++ b/astrbot/core/provider/sources/ollama_embedding_source.py @@ -48,7 +48,7 @@ async def _get_client(self): } timeout = aiohttp.ClientTimeout(total=self.timeout) self.client = aiohttp.ClientSession( - headers=headers, + headers={**self.request_headers, **headers}, timeout=timeout, trust_env=False, ) diff --git a/astrbot/core/provider/sources/openai_chat_completions_source.py b/astrbot/core/provider/sources/openai_chat_completions_source.py index 57fa62a74e..3577983352 100644 --- a/astrbot/core/provider/sources/openai_chat_completions_source.py +++ b/astrbot/core/provider/sources/openai_chat_completions_source.py @@ -463,31 +463,29 @@ def __init__(self, provider_config, provider_settings) -> None: self.api_keys: list = super().get_keys() self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None self.timeout = provider_config.get("timeout", 120) - self.custom_headers = provider_config.get("custom_headers", {}) + self.custom_headers = self.request_headers if isinstance(self.timeout, str): self.timeout = int(self.timeout) - if not isinstance(self.custom_headers, dict) or not self.custom_headers: - self.custom_headers = None - else: - for key in self.custom_headers: - self.custom_headers[key] = str(self.custom_headers[key]) - + api_base = provider_config.get("api_base") or None if provider_config.get("api_version"): # Using Azure OpenAI API - self.client = AsyncAzureOpenAI( - api_key=self.chosen_api_key, - api_version=provider_config.get("api_version", None), - default_headers=self.custom_headers, - base_url=provider_config.get("api_base", ""), - timeout=self.timeout, - http_client=self._create_http_client(provider_config), - ) + azure_kwargs = { + "api_key": self.chosen_api_key, + "api_version": provider_config.get("api_version", None), + "default_headers": self.custom_headers, + "timeout": self.timeout, + "http_client": self._create_http_client(provider_config), + } + if isinstance(api_base, str): + self.client = AsyncAzureOpenAI(base_url=api_base, **azure_kwargs) + else: + self.client = AsyncAzureOpenAI(**azure_kwargs) else: # Using OpenAI Official API self.client = AsyncOpenAI( api_key=self.chosen_api_key, - base_url=provider_config.get("api_base", None), + base_url=api_base if isinstance(api_base, str) else None, default_headers=self.custom_headers, timeout=self.timeout, http_client=self._create_http_client(provider_config), diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 6b640fdc8c..070aecafb1 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -44,6 +44,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: provider_config.get("embedding_api_base", "https://api.openai.com/v1") ) self.client = AsyncOpenAI( + default_headers=self.request_headers, api_key=provider_config.get("embedding_api_key"), base_url=api_base, timeout=int(provider_config.get("timeout", 20)), diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index 8fe61f6775..08d2fcf088 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -174,12 +174,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.api_keys = list(self.get_keys()) self.chosen_api_key = self.api_keys[0] if self.api_keys else "" self.timeout = int(provider_config.get("timeout", 120)) - headers = provider_config.get("custom_headers") - self.custom_headers = ( - {str(key): str(value) for key, value in headers.items()} - if isinstance(headers, dict) - else None - ) + self.custom_headers = self.request_headers client_options = { "api_key": self.chosen_api_key, "default_headers": self.custom_headers, diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index b1079fca8b..3ed08d4696 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -39,6 +39,7 @@ def __init__( "OpenAI TTS", provider_config, httpx_module=httpx2 ) self.client = AsyncOpenAI( + default_headers=self.request_headers, api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), timeout=timeout, diff --git a/astrbot/core/provider/sources/vllm_rerank_source.py b/astrbot/core/provider/sources/vllm_rerank_source.py index f2b2742f03..ff121feb40 100644 --- a/astrbot/core/provider/sources/vllm_rerank_source.py +++ b/astrbot/core/provider/sources/vllm_rerank_source.py @@ -34,7 +34,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.timeout = provider_config.get("timeout", 20) self.model = provider_config.get("rerank_model", "BAAI/bge-reranker-base") - h = {} + h = self.request_headers.copy() if self.auth_key: h["Authorization"] = f"Bearer {self.auth_key}" self.client = aiohttp.ClientSession( diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py index d28ef13cb0..27ddbf5f67 100644 --- a/astrbot/core/provider/sources/volcengine_tts.py +++ b/astrbot/core/provider/sources/volcengine_tts.py @@ -73,7 +73,7 @@ async def get_audio(self, text: str) -> str: try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession(headers=self.request_headers) as session, session.post( self.api_base, data=json.dumps(payload), diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 30baa05a2e..f5335d1bd9 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -26,6 +26,7 @@ def __init__( self.chosen_api_key = provider_config.get("api_key", "") self.client = AsyncOpenAI( + default_headers=self.request_headers, api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), timeout=provider_config.get("timeout", NOT_GIVEN), diff --git a/astrbot/core/provider/sources/xinference_rerank_source.py b/astrbot/core/provider/sources/xinference_rerank_source.py index ac6ccc805b..527ff4d4d4 100644 --- a/astrbot/core/provider/sources/xinference_rerank_source.py +++ b/astrbot/core/provider/sources/xinference_rerank_source.py @@ -49,6 +49,7 @@ async def initialize(self) -> None: else: logger.info("Xinference rerank does not use API authentication") self.client = Client(self.base_url) + self.client._headers.update(self.request_headers) running_models = await self.client.list_models() if not isinstance(running_models, Mapping): diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index 84bed2ac4f..3a891b3559 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -44,6 +44,7 @@ async def initialize(self) -> None: else: logger.info("Xinference STT: No API key provided.") self.client = Client(self.base_url) + self.client._headers.update(self.request_headers) try: running_models = await self.client.list_models() diff --git a/astrbot/core/tools/computer_tools/__init__.py b/astrbot/core/tools/computer_tools/__init__.py index 7cde0eb6cc..1c97e5b6d2 100644 --- a/astrbot/core/tools/computer_tools/__init__.py +++ b/astrbot/core/tools/computer_tools/__init__.py @@ -29,7 +29,13 @@ RunBrowserSkillTool, SyncSkillReleaseTool, ) -from .util import check_admin_permission, normalize_umo_for_workspace +from .util import ( + check_admin_permission, + check_local_execution_permission, + check_local_file_permission, + get_local_permission_policy, + normalize_umo_for_workspace, +) __all__ = [ "AnnotateExecutionTool", @@ -61,4 +67,7 @@ "SyncSkillReleaseTool", "normalize_umo_for_workspace", "check_admin_permission", + "check_local_execution_permission", + "check_local_file_permission", + "get_local_permission_policy", ] diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 3f43c2e549..b21354d5ea 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -29,19 +29,19 @@ import uuid from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, cast from astrbot import logger from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.file_read_utils import read_file_tool_result +from astrbot.core.computer.local_file_security import open_file_in_allowed_roots from astrbot.core.message.components import File, Image from astrbot.core.message.message_event_result import MessageChain from astrbot.core.utils.astrbot_path import ( get_astrbot_plugin_path, get_astrbot_skills_path, - get_astrbot_system_tmp_path, get_astrbot_temp_path, ) @@ -49,8 +49,11 @@ from . import util as computer_util from .util import ( check_admin_permission, + check_local_file_permission, + get_local_permission_policy, is_local_runtime, normalize_umo_for_workspace, + session_temp_roots, ) _COMPUTER_RUNTIME_TOOL_CONFIG = { @@ -79,8 +82,7 @@ def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> lis labels.extend( [ f"data/workspaces/{normalized_umo}", - get_astrbot_system_tmp_path(), - get_astrbot_temp_path(), + *(str(root) for root in session_temp_roots(umo)), ] ) return labels @@ -127,8 +129,7 @@ def _read_allowed_roots(umo: str) -> tuple[Path, ...]: *_plugin_skill_roots(), *_builtin_skill_roots(), _workspace_root(umo), - Path(get_astrbot_system_tmp_path()).resolve(strict=False), - Path(get_astrbot_temp_path()).resolve(strict=False), + *session_temp_roots(umo), ) @@ -136,15 +137,14 @@ def _write_allowed_roots(umo: str) -> tuple[Path, ...]: """Member writes never target global, plugin, or builtin Skill catalogs.""" return ( _workspace_root(umo), - Path(get_astrbot_system_tmp_path()).resolve(strict=False), - Path(get_astrbot_temp_path()).resolve(strict=False), + *session_temp_roots(umo), ) def _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool: if not is_local_runtime(context): return False - return True + return get_local_permission_policy(context).filesystem_scope != "host" def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str: @@ -293,6 +293,8 @@ async def call( path: str = kwargs["path"] offset: int | None = kwargs.get("offset", None) limit: int | None = kwargs.get("limit", None) + if permission_error := await check_local_file_permission(context): + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) try: @@ -318,18 +320,30 @@ async def call( context.context.context, context.context.event.unified_msg_origin, ) - return await read_file_tool_result( - sb, - local_mode=local_env, - path=normalized_path, - offset=offset, - limit=limit, - workspace_dir=( - str(_workspace_root(context.context.event.unified_msg_origin)) - if local_env - else None - ), - ) + file_descriptor = None + if restricted: + file_descriptor = open_file_in_allowed_roots( + normalized_path, + _read_allowed_roots(context.context.event.unified_msg_origin), + access="read", + ) + try: + return await read_file_tool_result( + sb, + local_mode=local_env, + path=normalized_path, + offset=offset, + limit=limit, + workspace_dir=( + str(_workspace_root(context.context.event.unified_msg_origin)) + if local_env + else None + ), + local_file_descriptor=file_descriptor, + ) + finally: + if file_descriptor is not None: + os.close(file_descriptor) except PermissionError as exc: return f"Error: {exc}" except Exception as exc: @@ -368,6 +382,8 @@ async def call( ) -> ToolExecResult: path: str = kwargs["path"] content: str = kwargs["content"] + if permission_error := await check_local_file_permission(context): + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) try: @@ -388,12 +404,30 @@ async def call( context.context.context, context.context.event.unified_msg_origin, ) - result = await sb.fs.write_file( - path=normalized_path, - content=content, - mode="w", - encoding="utf-8", - ) + filesystem = cast(Any, sb.fs) + file_descriptor = None + if restricted: + file_descriptor = open_file_in_allowed_roots( + normalized_path, + _write_allowed_roots(context.context.event.unified_msg_origin), + access="write", + create_parents=True, + ) + try: + result = await filesystem.write_file( + path=normalized_path, + content=content, + mode="w", + encoding="utf-8", + **( + {"file_descriptor": file_descriptor} + if file_descriptor is not None + else {} + ), + ) + finally: + if file_descriptor is not None: + os.close(file_descriptor) if not result.get("success", False): error_detail = str(result.get("error", "") or "").strip() return ( @@ -449,6 +483,8 @@ async def call( old: str = kwargs["old"] new: str = kwargs["new"] replace_all: bool = kwargs.get("replace_all", False) + if permission_error := await check_local_file_permission(context): + return permission_error umo = str(context.context.event.unified_msg_origin) local_env = is_local_runtime(context) restricted = _is_restricted_env(context) @@ -472,13 +508,30 @@ async def call( context.context.context, context.context.event.unified_msg_origin, ) - result = await sb.fs.edit_file( - path=normalized_path, - old_string=normalized_old, - new_string=normalized_new, - replace_all=replace_all, - encoding="utf-8", - ) + filesystem = cast(Any, sb.fs) + file_descriptor = None + if restricted: + file_descriptor = open_file_in_allowed_roots( + normalized_path, + _write_allowed_roots(umo), + access="edit", + ) + try: + result = await filesystem.edit_file( + path=normalized_path, + old_string=normalized_old, + new_string=normalized_new, + replace_all=replace_all, + encoding="utf-8", + **( + {"file_descriptor": file_descriptor} + if file_descriptor is not None + else {} + ), + ) + finally: + if file_descriptor is not None: + os.close(file_descriptor) if not result.get("success", False): error_detail = str(result.get("error", "") or "").strip() return ( @@ -658,6 +711,8 @@ async def call( normalized_pattern = pattern.strip() if not normalized_pattern: return "Error: `pattern` must be a non-empty string." + if permission_error := await check_local_file_permission(context): + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) diff --git a/astrbot/core/tools/computer_tools/python.py b/astrbot/core/tools/computer_tools/python.py index ade9d358e9..8672fe971c 100644 --- a/astrbot/core/tools/computer_tools/python.py +++ b/astrbot/core/tools/computer_tools/python.py @@ -1,6 +1,6 @@ import platform from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast import mcp @@ -10,7 +10,13 @@ from astrbot.core.message.message_event_result import MessageChain from ..registry import builtin_tool -from .util import check_admin_permission, is_local_runtime, workspace_root +from .fs import _read_allowed_roots, _write_allowed_roots +from .util import ( + LOCAL_NETWORK_POLICY_NOTICE, + check_admin_permission, + check_local_execution_permission, + workspace_root, +) _OS_NAME = platform.system() _SANDBOX_PYTHON_TOOL_CONFIG = { @@ -42,7 +48,9 @@ } -async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult: +async def handle_result( + result: dict, event: AstrMessageEvent +) -> mcp.types.CallToolResult: data = result.get("data", {}) output = data.get("output", {}) error = data.get("error", "") @@ -123,7 +131,8 @@ class LocalPythonTool(FunctionTool): name: str = "astrbot_execute_python" description: str = ( f"Execute codes in a Python environment. Current OS: {_OS_NAME}. " - "Use system-compatible commands." + "Use system-compatible commands. Restricted Linux and macOS calls run " + "inside an operating-system sandbox." ) parameters: dict = field(default_factory=lambda: param_schema) @@ -136,12 +145,15 @@ async def call( code: str = kwargs["code"] silent: bool = kwargs.get("silent", False) timeout_seconds: int = kwargs.get("timeout_seconds", 30) - if permission_error := await check_admin_permission( - context, "Python execution" - ): + local_policy, permission_error = await check_local_execution_permission( + context, + "Python execution", + ) + if permission_error: return permission_error - if not is_local_runtime(context): + if local_policy is None: return "Error executing code: only local runtime is supported." + sandboxed = local_policy.requires_sandbox sb = context.context.context.computer_runtime.get_local_booter() requested_timeout = kwargs.get("timeout") if requested_timeout is None: @@ -151,17 +163,44 @@ async def call( if requested_timeout > 0 else context.tool_call_timeout ) + if sandboxed: + effective_timeout = min(effective_timeout, 300) try: current_workspace_root = workspace_root( context.context.event.unified_msg_origin ) current_workspace_root.mkdir(parents=True, exist_ok=True) - result = await sb.python.exec( + python_component = cast(Any, sb.python) + sandbox_roots = {} + if sandboxed and local_policy.filesystem_scope == "workspace": + umo = context.context.event.unified_msg_origin + sandbox_roots = { + "readable_roots": _read_allowed_roots(umo), + "writable_roots": _write_allowed_roots(umo), + } + result = await python_component.exec( code, timeout_seconds=effective_timeout, silent=silent, cwd=str(current_workspace_root), + sandboxed=sandboxed, + allow_network=local_policy.allow_network, + filesystem_scope=local_policy.filesystem_scope, + **sandbox_roots, ) - return await handle_result(result, context.context.event) + response = await handle_result(result, context.context.event) + if not local_policy.allow_network: + response.content.insert( + 0, + mcp.types.TextContent( + type="text", text=LOCAL_NETWORK_POLICY_NOTICE + ), + ) + return response except Exception as e: - return f"Error executing code: {str(e)}" + policy_notice = ( + f"{LOCAL_NETWORK_POLICY_NOTICE}\n" + if not local_policy.allow_network + else "" + ) + return f"{policy_notice}Error executing code: {str(e)}" diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index 6c74af1524..dc7498906d 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -14,7 +14,14 @@ from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path from ..registry import builtin_tool -from .util import check_admin_permission, is_local_runtime, workspace_root +from .fs import _read_allowed_roots, _write_allowed_roots +from .util import ( + LOCAL_NETWORK_POLICY_NOTICE, + check_local_execution_permission, + get_local_permission_policy, + is_local_runtime, + workspace_root, +) _COMPUTER_RUNTIME_TOOL_CONFIG = { "provider_settings.computer_use_runtime": ("local", "sandbox"), @@ -101,11 +108,21 @@ async def call( ) -> ToolExecResult: command: str = kwargs["command"] background: bool = kwargs.get("background", False) - timeout_seconds: int | None = kwargs.get("timeout_seconds", None) + timeout_seconds: int | None = kwargs.get("timeout") env: dict[str, Any] | None = kwargs.get("env", None) yield_time_ms: int = kwargs.get("yield_time_ms", 10_000) - if permission_error := await check_admin_permission(context, "Shell execution"): + local_policy, permission_error = await check_local_execution_permission( + context, + "Shell execution", + ) + if permission_error: return permission_error + sandboxed = bool(local_policy and local_policy.requires_sandbox) + policy_notice = ( + f"{LOCAL_NETWORK_POLICY_NOTICE}\n" + if local_policy and not local_policy.allow_network + else "" + ) sb = await context.context.context.computer_runtime.get_booter( context.context.context, @@ -121,26 +138,56 @@ async def call( current_workspace_root.mkdir(parents=True, exist_ok=True) cwd = str(current_workspace_root) - if not isinstance(sb.shell, LocalShellComponent): + shell_component = sb.shell + if not isinstance(shell_component, LocalShellComponent): return ( "Error executing command: local shell component is unavailable." ) - return json.dumps( - await sb.shell.exec_managed( - command, - owner_id=context.context.event.unified_msg_origin, - runtime_id="local", - sender_id=str(context.context.event.get_sender_id()), - cwd=cwd, - env=dict(env or {}), - timeout=kwargs.get("timeout", timeout_seconds) - if kwargs.get("timeout", timeout_seconds) is not None - else None, - yield_time_ms=0 if background else yield_time_ms, - allowed_root=str(current_workspace_root), + creator_id = str(context.context.event.get_sender_id()) + creator_is_admin = ( + get_local_permission_policy(context).filesystem_scope == "host" + or local_policy is not None + and local_policy.allow_network + and not local_policy.requires_sandbox + ) + umo = context.context.event.unified_msg_origin + workspace_scope = ( + local_policy is not None + and local_policy.filesystem_scope == "workspace" + ) + requested_timeout = timeout_seconds + result = await shell_component.exec_managed( + command, + owner_id=context.context.event.unified_msg_origin, + runtime_id="local", + sender_id=creator_id, + creator_is_admin=creator_is_admin, + sandboxed=sandboxed, + permission_check=lambda: ( + is_local_runtime(context) + and get_local_permission_policy(context) == local_policy + ), + allow_network=( + local_policy.allow_network if local_policy else True + ), + filesystem_scope=( + local_policy.filesystem_scope if local_policy else "host" ), - ensure_ascii=False, + cwd=cwd, + env=dict(env or {}), + timeout=( + min(requested_timeout or 300, 300) + if sandboxed + else requested_timeout + ), + yield_time_ms=0 if background else yield_time_ms, + allowed_root=str(current_workspace_root), + readable_roots=_read_allowed_roots(umo) if workspace_scope else (), + writable_roots=_write_allowed_roots(umo) if workspace_scope else (), ) + if policy_notice: + result["policy_notice"] = LOCAL_NETWORK_POLICY_NOTICE + return json.dumps(result, ensure_ascii=False) env = dict(env or {}) effective_background = background and not _is_self_detached_command(command) @@ -177,7 +224,7 @@ async def call( return json.dumps(result, ensure_ascii=False) except Exception as e: detail = str(e) or type(e).__name__ - return f"Error executing command: {detail}" + return f"{policy_notice}Error executing command: {detail}" @builtin_tool(config=_LOCAL_RUNTIME_TOOL_CONFIG, required_actions=("tool.local_exec",)) @@ -237,11 +284,13 @@ async def call( cursor: int | None = kwargs.get("cursor", None) yield_time_ms: int = kwargs.get("yield_time_ms", 5_000) max_output_chars: int = kwargs.get("max_output_chars", 10_000) - if permission_error := await check_admin_permission( - context, "Shell session management" - ): + _, permission_error = await check_local_execution_permission( + context, + "Shell session management", + ) + if permission_error and action != "terminate": return permission_error - if not is_local_runtime(context): + if not is_local_runtime(context) and action != "terminate": return "Error managing shell session: only local runtime is supported." owner_id = context.context.event.unified_msg_origin sender_id = str(context.context.event.get_sender_id()) diff --git a/astrbot/core/tools/computer_tools/util.py b/astrbot/core/tools/computer_tools/util.py index 9b6835800f..04b233c0e0 100644 --- a/astrbot/core/tools/computer_tools/util.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -1,10 +1,18 @@ import re +from dataclasses import dataclass from pathlib import Path +from typing import Literal from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.astr_agent_context import AstrAgentContext -from astrbot.core.auth.models import Resource -from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path +from astrbot.core.auth.models import INSTANCE_TOOL_ROLES, Resource, Role +from astrbot.core.computer.process_sandbox import create_process_sandbox +from astrbot.core.config.default import get_local_permission_defaults +from astrbot.core.utils.astrbot_path import ( + get_astrbot_system_tmp_path, + get_astrbot_temp_path, + get_astrbot_workspaces_path, +) def normalize_umo_for_workspace(umo: str) -> str: @@ -12,12 +20,49 @@ def normalize_umo_for_workspace(umo: str) -> str: return normalized or "unknown" +LOCAL_NETWORK_POLICY_NOTICE = ( + "Sandbox policy: Network access is disabled for local Shell/Python execution. " + "Do not retry the same network operation with another command, Python, " + "HTTP/HTTPS, or disabled certificate verification; these do not change the policy. " + "Local offline operations are still allowed." +) + + +@dataclass(frozen=True) +class LocalPermissionPolicy: + """Resolved Local computer permissions for one caller. + + Args: + allow_execution: Whether Shell and Python execution is allowed. + allow_network: Whether the execution environment may use the network. + filesystem_scope: Host or workspace access, or none to disable Local tools. + """ + + allow_execution: bool + allow_network: bool + filesystem_scope: Literal["none", "workspace", "host"] + + @property + def requires_sandbox(self) -> bool: + """Return whether execution needs operating-system isolation.""" + return not self.allow_network or self.filesystem_scope != "host" + + def workspace_root(umo: str) -> Path: """Root directory for relative paths in local runtime""" normalized_umo = normalize_umo_for_workspace(umo) return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False) +def session_temp_roots(umo: str) -> tuple[Path, ...]: + """Per-session temp directories that other callers cannot read or write.""" + key = normalize_umo_for_workspace(umo) + return ( + (Path(get_astrbot_system_tmp_path()) / key).resolve(strict=False), + (Path(get_astrbot_temp_path()) / key).resolve(strict=False), + ) + + def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: cfg = context.context.context.get_config( umo=context.context.event.unified_msg_origin @@ -27,14 +72,133 @@ def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: return runtime == "local" +def _role_from_effective(effective: object) -> str: + """Map an authorization role onto a Local permission matrix row.""" + if effective in INSTANCE_TOOL_ROLES or effective in { + Role.INSTANCE_OPERATOR, + Role.OPERATOR, + Role.ROOT, + "instance_operator", + "operator", + "root", + }: + return "admin" + return "member" + + +def _stamp_permission_role(event: object, effective: object) -> str: + role = _role_from_effective(effective) + setattr(event, "_computer_permission_role", role) + return role + + +def _local_permission_role(context: ContextWrapper[AstrAgentContext]) -> str: + """Return the cached Local permission matrix row for this event.""" + stored = getattr(context.context.event, "_computer_permission_role", None) + if stored in {"admin", "member"}: + return stored + return "member" + + +async def resolve_local_permission_role( + context: ContextWrapper[AstrAgentContext], +) -> str: + """Resolve the Local matrix row from authorization, not tool-call order.""" + event = context.context.event + stored = getattr(event, "_computer_permission_role", None) + if stored in {"admin", "member"}: + return stored + authorization = getattr(context.context.context, "authorization", None) + if authorization is None or event.subject is None or event.auth_context is None: + return "member" + config_id = ( + event.resource.config_id + if event.resource is not None + else event.auth_context.config_id + ) + decision = await authorization.authorize( + event.subject, + "tool.file_read", + Resource.named("tool", "local-permission-role", config_id=config_id), + event.auth_context, + ) + return _stamp_permission_role(event, getattr(decision, "effective_role", None)) + + +def get_local_permission_policy( + context: ContextWrapper[AstrAgentContext], +) -> LocalPermissionPolicy: + """Resolve the Local permission policy for the caller's role. + + Args: + context: Tool call context. + + Returns: + Normalized policy. Unknown roles use the member policy. + """ + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + role = _local_permission_role(context) + defaults = get_local_permission_defaults()[role] + + permissions = provider_settings.get("computer_use_local_permissions") + role_policy = permissions.get(role) if isinstance(permissions, dict) else None + if not isinstance(role_policy, dict): + role_policy = {} + + filesystem_scope = role_policy.get("filesystem_scope", defaults["filesystem_scope"]) + if filesystem_scope not in {"none", "workspace", "host"}: + filesystem_scope = defaults["filesystem_scope"] + allow_execution = ( + filesystem_scope != "none" + and role_policy.get("allow_execution", defaults["allow_execution"]) is True + ) + allow_network = ( + allow_execution + and role_policy.get("allow_network", defaults["allow_network"]) is True + ) + return LocalPermissionPolicy( + allow_execution=allow_execution, + allow_network=allow_network, + filesystem_scope=filesystem_scope, + ) + + +async def check_local_file_permission( + context: ContextWrapper[AstrAgentContext], +) -> str | None: + """Reject file tools when Local access is disabled for the caller's role. + + Args: + context: Tool call context. + + Returns: + A permission error, or None when the file tool may proceed. + """ + await resolve_local_permission_role(context) + if ( + is_local_runtime(context) + and get_local_permission_policy(context).filesystem_scope == "none" + ): + return ( + "error: Permission denied. Local computer tools are disabled for this " + "user role. Enable Local computer access for this role in AstrBot " + "WebUI -> Config -> Agent Computer Use -> Local Permission Policies." + ) + return None + + async def check_admin_permission( context: ContextWrapper[AstrAgentContext], operation_name: str ) -> str | None: """Run the final action check immediately before a sensitive operation.""" event = context.context.event - action, resource_id = { + mapped = { "Shell execution": ("tool.local_exec", "shell-execution"), + "Shell session management": ("tool.local_exec", "shell-session"), "Python execution": ("tool.python_exec", "python-execution"), "File upload/download": ("tool.file_write", "file-transfer"), "Taking CUA screenshots": ("tool.computer_use", "cua-screenshot"), @@ -44,7 +208,10 @@ async def check_admin_permission( "Using skill lifecycle tools": ("extension.manage", "skill-lifecycle"), "Send a poke to another user": ("agent.manage", "send-poke"), "Send message to another session": ("agent.manage", "send-message"), - }.get(operation_name, ("tool.local_exec", "sensitive-operation")) + }.get(operation_name) + if mapped is None: + raise ValueError(f"Unsupported local permission operation: {operation_name}.") + action, resource_id = mapped authorization = getattr(context.context.context, "authorization", None) if authorization is None or event.subject is None or event.auth_context is None: return "error: Permission denied. Authorization context is unavailable." @@ -59,9 +226,49 @@ async def check_admin_permission( Resource.named("tool", resource_id, config_id=config_id), event.auth_context, ) + _stamp_permission_role(event, getattr(decision, "effective_role", None)) if not decision.allowed: return ( f"error: Permission denied. {operation_name} requires an authorized action. " f"User's ID is: {event.get_sender_id()}." ) return None + + +async def check_local_execution_permission( + context: ContextWrapper[AstrAgentContext], + operation_name: str, +) -> tuple[LocalPermissionPolicy | None, str | None]: + """Resolve whether an execution tool needs an operating-system sandbox. + + Args: + context: Tool call context. + operation_name: User-facing name included in permission errors. + + Returns: + Resolved Local policy and an optional error. Non-Local runtimes return + no policy because their existing authorization gate is unchanged. + """ + if permission_error := await check_admin_permission(context, operation_name): + return None, permission_error + if not is_local_runtime(context): + return None, None + policy = get_local_permission_policy(context) + if not policy.allow_execution: + return policy, ( + f"error: Permission denied. {operation_name} is disabled by the " + "Local permission policy for this user role. Enable Local computer " + "access and `Execute code` " + "for this role in AstrBot WebUI -> Config -> Agent Computer Use -> " + "Local Permission Policies." + ) + if policy.requires_sandbox: + try: + create_process_sandbox() + except RuntimeError as exc: + return policy, ( + "error: Permission denied. Restricted Local execution is unavailable: " + f"{exc} Select `Third-party sandbox` under AstrBot WebUI -> Config -> " + "Agent Computer Use -> Computer Use Runtime." + ) + return policy, None diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index f4c7c4251c..d4174f2092 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -10,6 +10,7 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.cron.manager import CronJobSchedulingError +from astrbot.core.platform.message_type import MessageType from astrbot.core.tools.registry import builtin_tool _CRON_TOOL_CONFIG = { @@ -188,7 +189,12 @@ async def _create_future_task( return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." async def _delete_future_task( - self, cron_mgr, job_id: object, current_umo: str, current_sender_id: str + self, + context: ContextWrapper[AstrAgentContext], + cron_mgr, + job_id: object, + current_umo: str, + current_sender_id: str, ) -> ToolExecResult: if not job_id: return "error: job_id is required when action=delete." @@ -196,7 +202,27 @@ async def _delete_future_task( if not job: return f"error: cron job {job_id} not found." if not _job_belongs_to_current_sender(job, current_umo, current_sender_id): - return "error: you can only delete your own future tasks." + same_session = _extract_job_session(job) == current_umo + if same_session and not _extract_job_sender(job): + return ( + f"error: cron job {job_id} has no chat member as its creator " + "(it was created outside this chat, e.g. from the dashboard), " + "so you cannot delete it here." + ) + if ( + same_session + and context.context.event.get_message_type() + == MessageType.GROUP_MESSAGE + ): + return ( + f"error: cron job {job_id} was created by another member of " + "this group chat, so you cannot delete it. Only the member who " + "created it can delete it; tell the user to ask that member." + ) + return ( + f"error: cron job {job_id} was not created by you, so you cannot " + "delete it. Only whoever created it can delete it." + ) await cron_mgr.delete_job(str(job_id)) return f"Deleted cron job {job_id}." @@ -207,23 +233,40 @@ async def _list_future_tasks( current_umo: str, current_sender_id: str, ) -> ToolExecResult: + all_jobs = await cron_mgr.list_jobs() jobs = [ job - for job in await cron_mgr.list_jobs() + for job in all_jobs if _job_belongs_to_current_sender(job, current_umo, current_sender_id) ] + hidden_note = "" + for job in all_jobs: + if _extract_job_session(job) != current_umo: + continue + if _job_belongs_to_current_sender(job, current_umo, current_sender_id): + continue + hidden_note = ( + "\n\nNote: tasks in this chat that were not created by you " + "are not listed here, and can only be edited or deleted by " + "whoever created them." + ) + break if not jobs: - return "No cron jobs found." + return "No cron jobs found." + hidden_note _, timezone_info = _get_configured_timezone(context) - return "\n".join( - f"{job.job_id} | {job.name} | {job.job_type} | " - f"run_once={getattr(job, 'run_once', False)} | enabled={job.enabled} | " - f"next={_display_next_run_time(job.next_run_time, timezone_info)}" - for job in jobs + return ( + "\n".join( + f"{job.job_id} | {job.name} | {job.job_type} | " + f"run_once={getattr(job, 'run_once', False)} | enabled={job.enabled} | " + f"next={_display_next_run_time(job.next_run_time, timezone_info)}" + for job in jobs + ) + + hidden_note ) async def _edit_future_task( self, + context: ContextWrapper[AstrAgentContext], cron_mgr, kwargs: dict[str, Any], current_umo: str, @@ -241,7 +284,27 @@ async def _edit_future_task( if not job: return f"error: cron job {job_id} not found." if not _job_belongs_to_current_sender(job, current_umo, current_sender_id): - return "error: you can only edit your own future tasks." + same_session = _extract_job_session(job) == current_umo + if same_session and not _extract_job_sender(job): + return ( + f"error: cron job {job_id} has no chat member as its creator " + "(it was created outside this chat, e.g. from the dashboard), " + "so you cannot edit it here." + ) + if ( + same_session + and context.context.event.get_message_type() + == MessageType.GROUP_MESSAGE + ): + return ( + f"error: cron job {job_id} was created by another member of " + "this group chat, so you cannot edit it. Only the member who " + "created it can edit it; tell the user to ask that member." + ) + return ( + f"error: cron job {job_id} was not created by you, so you cannot " + "edit it. Only whoever created it can edit it." + ) payload = dict(job.payload) if isinstance(job.payload, dict) else {} updates: dict[str, Any] = {} @@ -313,11 +376,12 @@ async def call( current_sender_id = str(context.context.event.get_sender_id()) if action == "edit": return await self._edit_future_task( - cron_mgr, kwargs, current_umo, current_sender_id + context, cron_mgr, kwargs, current_umo, current_sender_id ) if action == "delete": return await self._delete_future_task( + context, cron_mgr, kwargs.get("job_id"), current_umo, diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 6767b4e326..e19afef181 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -23,26 +23,20 @@ from astrbot.core.tools.computer_tools.fs import _remote_basename from astrbot.core.tools.computer_tools.util import ( check_admin_permission, + get_local_permission_policy, is_local_runtime, + resolve_local_permission_role, + session_temp_roots, workspace_root, ) from astrbot.core.tools.registry import builtin_tool -from astrbot.core.utils.astrbot_path import ( - get_astrbot_system_tmp_path, - get_astrbot_temp_path, -) def _file_send_allowed_roots(umo: str | None) -> tuple[Path, ...]: roots = [] if umo: roots.append(workspace_root(umo)) - roots.extend( - [ - Path(get_astrbot_temp_path()).resolve(strict=False), - Path(get_astrbot_system_tmp_path()).resolve(strict=False), - ] - ) + roots.extend(session_temp_roots(umo)) return tuple(roots) @@ -51,9 +45,9 @@ def _is_path_within(path: Path, roots: tuple[Path, ...]) -> bool: def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool: - if not is_local_runtime(context): - return False - return True + return is_local_runtime(context) and ( + get_local_permission_policy(context).filesystem_scope != "host" + ) def _can_send_local_file( @@ -136,6 +130,7 @@ async def _resolve_path_from_sandbox( path = str(path).strip() if not path: raise FileNotFoundError(f"{component_type} path is empty") + await resolve_local_permission_role(context) # Relative host paths are resolved only inside the user's workspace. if not os.path.isabs(path): @@ -166,6 +161,12 @@ async def _resolve_path_from_sandbox( f"Blocked path: {local_candidate}." ) + # Local runtime has no separate sandbox: the workspace and local-file + # branches above already enforced the caller's permissions, so probing + # the host shell here would bypass them and expose host paths. + if is_local_runtime(context): + raise FileNotFoundError(f"{component_type} path does not exist: {path}") + try: sb = await context.context.context.computer_runtime.get_booter( context.context.context, @@ -175,8 +176,12 @@ async def _resolve_path_from_sandbox( result = await sb.shell.exec(f"test -f {quoted_path} && echo '_&exists_'") if "_&exists_" in json.dumps(result): name = _remote_basename(path) or os.path.basename(path) + session_temp = session_temp_roots( + context.context.event.unified_msg_origin + )[1] + session_temp.mkdir(parents=True, exist_ok=True) local_path = os.path.join( - get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + str(session_temp), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" ) await sb.download_file(path, local_path) logger.info(f"Downloaded file from sandbox: {path} -> {local_path}") diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py index 1a364685c8..1d1c3a4db0 100644 --- a/astrbot/dashboard/api/app.py +++ b/astrbot/dashboard/api/app.py @@ -10,6 +10,7 @@ from astrbot import logger from astrbot.core.agent.mcp_client import validate_mcp_server_config +from astrbot.core.computer.process_sandbox import detect_local_runtime_info from astrbot.core.core_runtime import CoreControl, CoreRuntime from astrbot.core.db.sqlite import SQLiteDatabase from astrbot.core.skills.skill_manager import SkillManager @@ -226,6 +227,7 @@ async def save_managed_core_config(relative_path: str, next_config: dict) -> Non logger.error("Failed to roll back managed Dashboard configuration") raise + computer_runtime_info = detect_local_runtime_info(probe=True) app.state.services = SimpleNamespace( appearance=AppearanceService(), config_profiles=ConfigProfileService( @@ -235,6 +237,8 @@ async def save_managed_core_config(relative_path: str, next_config: dict) -> Non runtime.services.totp_runtime_state, db, runtime.plugin_manager.catalog, + runtime=computer_runtime_info, + computer_runtime=runtime.services.computer_runtime, ), config_display=ConfigDisplayService( runtime.astrbot_config, @@ -361,6 +365,7 @@ async def save_managed_core_config(relative_path: str, next_config: dict) -> Non html_renderer=runtime.services.html_renderer, plugin_catalog=runtime.catalogs.plugins, platform_manager=runtime.platform_manager, + runtime=computer_runtime_info, ), subagents=SubAgentService( runtime.astrbot_config, diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index 94529d8253..0c12f398b5 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -10,6 +10,8 @@ from astrbot import logger from astrbot.core.astrbot_config_mgr import AstrBotConfigManager +from astrbot.core.computer.booters.local import LocalShellComponent +from astrbot.core.computer.process_sandbox import detect_local_runtime_info from astrbot.core.config.agent_runner import ( get_agent_runner_config_default, normalize_agent_runner, @@ -21,6 +23,7 @@ CONFIG_METADATA_3_SYSTEM, DEFAULT_CONFIG, DEFAULT_VALUE_MAP, + get_local_permission_defaults, ) from astrbot.core.config.i18n_utils import ConfigMetadataI18n from astrbot.core.core_runtime import CoreControl @@ -419,7 +422,14 @@ def sanitize_filename(name: str) -> str: return _sanitize_filename(name) -def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]: +def validate_config( + data, + schema: dict, + is_core: bool, + *, + runtime: dict | None = None, + current_config: dict | None = None, +) -> tuple[list[str], dict]: errors = [] def validate(data: dict, metadata: dict = schema, path="") -> None: @@ -520,12 +530,105 @@ def validate(data: dict, metadata: dict = schema, path="") -> None: validate(data, meta_all) if isinstance(data, dict): AstrBotConfig._strip_unknown_config_keys(DEFAULT_CONFIG, data) + _validate_local_permissions( + data, + errors, + runtime=runtime, + current_config=current_config, + ) else: validate(data, schema) return errors, data +def _validate_local_permissions( + data: dict, + errors: list[str], + *, + runtime: dict | None, + current_config: dict | None, +) -> None: + provider_settings = data.get("provider_settings", {}) + defaults = get_local_permission_defaults(runtime.get("os") if runtime else None) + permissions = ( + provider_settings.get("computer_use_local_permissions", {}) + if isinstance(provider_settings, dict) + else {} + ) + submitted_permissions = copy.deepcopy(permissions) + if not isinstance(permissions, dict): + errors.append("Local computer permissions must be an object.") + return + for role in ("member", "admin"): + if role not in permissions: + continue + policy = permissions[role] + if not isinstance(policy, dict): + errors.append(f"Local computer permissions for {role} must be an object.") + continue + for key in ("allow_execution", "allow_network"): + if key in policy and not isinstance(policy[key], bool): + errors.append(f"Local permission {role}.{key} must be a boolean.") + scope = policy.get("filesystem_scope", defaults[role]["filesystem_scope"]) + if scope not in ("none", "workspace", "host"): + errors.append(f"Invalid local filesystem scope for {role}: {scope}.") + if scope == "none": + policy["allow_execution"] = False + policy["allow_network"] = False + elif policy.get("allow_execution", defaults[role]["allow_execution"]) is False: + policy["allow_network"] = False + + if ( + errors + or runtime is None + or not isinstance(provider_settings, dict) + or provider_settings.get("computer_use_runtime") != "local" + or runtime["sandbox"]["status"] == "detected" + ): + return + old_settings = (current_config or {}).get("provider_settings", {}) + old_permissions = old_settings.get("computer_use_local_permissions", {}) + was_local = old_settings.get("computer_use_runtime") == "local" + for role in ("member", "admin"): + if was_local and submitted_permissions.get(role, {}) == old_permissions.get( + role, {} + ): + continue + policy = {**defaults[role], **permissions.get(role, {})} + scope = policy["filesystem_scope"] + if scope == "none": + continue + unsupported = runtime["sandbox"]["status"] == "unsupported" + if not ( + (unsupported and scope == "workspace") + or ( + policy["allow_execution"] + and (scope == "workspace" or not policy["allow_network"]) + ) + ): + continue + if unsupported: + reason = f"Local isolation is not supported on {runtime['os']}." + else: + dependency = ( + "Seatbelt (/usr/bin/sandbox-exec)" + if runtime["sandbox"]["backend"] == "seatbelt" + else "bubblewrap (bwrap)" + ) + if runtime["sandbox"]["status"] == "unavailable": + detail = runtime["sandbox"].get("error", "Sandbox startup failed.") + reason = ( + f"{dependency} is installed but cannot start a sandbox: " + f"{detail} Restricted Local execution is unavailable." + ) + else: + reason = ( + f"Missing {dependency}; restricted Local execution is unavailable." + ) + errors.append(f"Local permission {role}: {reason}") + + def _log_computer_config_changes( old_config: dict, new_config: dict, @@ -545,6 +648,23 @@ def _log_computer_config_changes( new_runtime, ) + old_permissions = old_ps.get("computer_use_local_permissions", {}) + new_permissions = new_ps.get("computer_use_local_permissions", {}) + for role in ("member", "admin"): + old_role = old_permissions.get(role, {}) + new_role = new_permissions.get(role, {}) + for key in ("allow_execution", "allow_network", "filesystem_scope"): + old_value = old_role.get(key) + new_value = new_role.get(key) + if old_value != new_value: + log_info( + "[Computer] Config changed: local_permissions.%s.%s %s -> %s", + role, + key, + old_value, + new_value, + ) + old_sandbox = old_ps.get("sandbox", {}) new_sandbox = new_ps.get("sandbox", {}) all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys()) @@ -682,6 +802,8 @@ async def save_config_async( post_config, CONFIG_METADATA_2, is_core, + runtime=detect_local_runtime_info(probe=False), + current_config=current_config, ) else: errors, post_config = validate_config( @@ -744,6 +866,8 @@ def __init__( totp_runtime_state: TotpRuntimeState, db: DatabaseSessionStore | None = None, plugin_catalog=None, + runtime: dict | None = None, + computer_runtime=None, ) -> None: self.core_control = core_control self.acm = config_manager @@ -751,6 +875,10 @@ def __init__( self.db = db self.totp_runtime_state = totp_runtime_state self.plugin_catalog = plugin_catalog + self.runtime = ( + runtime if runtime is not None else detect_local_runtime_info(probe=True) + ) + self.computer_runtime = computer_runtime def get_profile_schema(self) -> dict: return { @@ -806,6 +934,14 @@ async def create_profile( ) except ValueError as exc: raise DashboardValidationError(str(exc)) from exc + errors, profile_config = validate_config( + profile_config, + CONFIG_METADATA_2, + is_core=True, + runtime=self.runtime, + ) + if errors: + raise DashboardValidationError(f"格式校验未通过: {errors}") conf_id = await self.acm.create_conf(name=name, config=profile_config) await self.core_control.reload_pipeline_scheduler(conf_id) return {"conf_id": conf_id} @@ -881,6 +1017,13 @@ async def update_profile( ) if protected_2fa_changed: await self.totp_runtime_state.clear_all() + if self.computer_runtime is not None: + try: + booter = self.computer_runtime.get_local_booter() + except RuntimeError: + booter = None + if booter is not None and isinstance(booter.shell, LocalShellComponent): + await booter.shell.shutdown_sessions(invalid_only=True) await self.core_control.reload_pipeline_scheduler(config_id) warning = await _validate_neo_connectivity(config) if warning: diff --git a/astrbot/dashboard/services/stat_service.py b/astrbot/dashboard/services/stat_service.py index cf2c954b0d..023b72a4e5 100644 --- a/astrbot/dashboard/services/stat_service.py +++ b/astrbot/dashboard/services/stat_service.py @@ -13,6 +13,7 @@ from sqlmodel import col, func, select from astrbot import logger +from astrbot.core.computer.process_sandbox import detect_local_runtime_info from astrbot.core.config import VERSION from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.core_runtime import CoreControl @@ -57,6 +58,7 @@ def __init__( html_renderer: HtmlRenderer, plugin_catalog: PluginRegistry, platform_manager: PlatformManager, + runtime: dict | None = None, ) -> None: self.db_helper = db_helper self.core_control = core_control @@ -67,6 +69,9 @@ def __init__( self.plugin_catalog = plugin_catalog self.platform_manager = platform_manager self.storage_cleaner = StorageCleaner(config) + self.runtime = ( + runtime if runtime is not None else detect_local_runtime_info(probe=True) + ) async def restart_core(self) -> None: if self.demo_mode: @@ -119,6 +124,7 @@ async def get_version(self) -> dict: "change_pwd_hint": await self.is_default_cred(), "md5_pwd_hint": md5_pwd_hint, "password_upgrade_required": not storage_upgraded, + "runtime": self.runtime, } async def get_public_versions( diff --git a/changelogs/v4.28.1.md b/changelogs/v4.28.1.md index c87beebdf1..575a1103ae 100644 --- a/changelogs/v4.28.1.md +++ b/changelogs/v4.28.1.md @@ -1,4 +1,4 @@ -## [4.28.1] - 2026-09-11 +## [4.28.1] - 2026-09-14 ### Added @@ -54,6 +54,10 @@ - Dashboard numeric fields and object editors enforce declared slider bounds for both slider and text input paths. The Dashboard bundle also removes unused font and diagram assets. (#10034, #10037) +- Group Dashboard model selection by provider source, with source filters + and sticky group headers. (#10084) +- Standardize provider request User-Agent headers to `astrbot/`. + (#10082) ### Fixed @@ -89,6 +93,14 @@ configuration translations. - Use `NullPool` for SQLite async engines on affected platforms and release knowledge-base ingestion capacity on cancellation. (#92, #102) +- Always send the QQ Official C2C streaming `state=10` closing frame, + including empty tails after a middle flush. (#10069) +- Treat an empty OpenAI `api_base` as unset so the SDK keeps its default + endpoint. (#10079) +- Reduce Telegram polling and Conflict error log spam. (#10027, #10046) +- Skip still-image resize in CUA sandbox sessions so pixel coordinates stay + 1:1, keep always-JPEG conversion, and warn when unresized images exceed + 5 MB. (#10071) ### Documentation @@ -121,6 +133,8 @@ - The ChatUI settings/composer redesign from upstream #10021 was reverted before this branch was published; only the independently integrated reasoning toggle and workspace preview behavior remain. +- Skipped the upstream 4.28.1 version-bump commit (#10085). This fork + already ships 4.28.1 and does not copy upstream changelog text. ## 中文 @@ -165,6 +179,8 @@ 仍只显示元数据。 (#10023) - Dashboard 数字字段和对象编辑器在滑块与文本输入路径都遵守声明的边界,并移除 未使用的字体和图表资源。 (#10034, #10037) +- 模型选择器按提供商源分组,并增加源筛选和分组标题吸顶。 (#10084) +- 统一提供商请求 User-Agent 为 `astrbot/`。 (#10082) ### 修复 @@ -189,6 +205,12 @@ - 保留 Telegram 私聊主题路由,并补齐 Dashboard Telegram 配置翻译。 - 受影响平台的 SQLite 异步引擎使用 `NullPool`,知识库导入取消时释放容量。 (#92, #102) +- QQ 官方机器人 C2C 流式始终发送 `state=10` 收尾帧,包括中间分片清空后的空尾。 + (#10069) +- 空的 OpenAI `api_base` 视为未设置,沿用 SDK 默认接口。 (#10079) +- 减少 Telegram 轮询和 Conflict 错误的重复日志。 (#10027, #10046) +- CUA 沙箱会话跳过静态图缩放以保持像素坐标 1:1,仍始终转 JPEG,超过 5 MB 时告警。 + (#10071) ### 文档 @@ -220,3 +242,4 @@ 中实际存在的功能。 - 上游 #10021 的 ChatUI 设置/输入框重设计在本分支发布前已回滚;仅保留独立集成的 reasoning 开关和工作区预览行为。 +- 跳过上游 4.28.1 版本 bump(#10085)。本 fork 已是 4.28.1,不抄上游 changelog。 diff --git a/changelogs/v4.28.2.md b/changelogs/v4.28.2.md new file mode 100644 index 0000000000..a3d4b27acc --- /dev/null +++ b/changelogs/v4.28.2.md @@ -0,0 +1,13 @@ +## [4.28.2] - [WIP] + +### Fork Deviations + +- Upstream release wording and upstream-only artifact links are not copied + verbatim; this changelog describes the features present in the fork. + +## 中文 + +### Fork 差异 + +- 不原样复制上游发布说明和仅属于上游的产物链接;本 changelog 只描述 fork 中实际 + 存在的功能。 diff --git a/dashboard/src/api/generated/openapi-v1/index.ts b/dashboard/src/api/generated/openapi-v1/index.ts index 895394e8ee..5c40529dd2 100644 --- a/dashboard/src/api/generated/openapi-v1/index.ts +++ b/dashboard/src/api/generated/openapi-v1/index.ts @@ -1182,6 +1182,7 @@ export type { RunCronJobData, RunCronJobResponse, RunCronJobResponses, + RuntimeInfo, SearchDataFilesData, SearchDataFilesResponse, SearchDataFilesResponses, diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 7b68cbada4..e8d7ff6ce8 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -5688,7 +5688,9 @@ export const getProviderTokenStats = ( }); /** - * Get AstrBot version + * Get AstrBot version and runtime information + * + * Runtime information is detected once at application startup. Restart AstrBot after installing sandbox dependencies to refresh it. */ export const getVersion = ( options?: Options, diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index b96b2a562f..e5272eab21 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -4,6 +4,34 @@ export type ClientOptions = { baseURL: 'http://localhost:6185' | (string & {}); }; +/** + * The AstrBot backend runtime, including when running inside a container. Values are captured at application startup. + */ +export type RuntimeInfo = { + /** + * Lowercase platform.system() value, commonly linux, darwin, or windows. + */ + os: string; + /** + * Unmodified platform.machine() value, such as x86_64, AMD64, arm64, or aarch64. May be empty if unknown. + */ + arch: string; + /** + * Local process sandbox startup check, captured when AstrBot starts. It does not verify DNS resolution or every permitted operation. + */ + sandbox: { + backend: 'bubblewrap' | 'seatbelt' | null; + /** + * detected means the executable was found and a minimal workspace sandbox launched successfully; missing means the corresponding executable was not found; unavailable means it was found but sandbox startup failed; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language. + */ + status: 'detected' | 'missing' | 'unavailable' | 'unsupported'; + /** + * Bounded startup error detail, included when status is unavailable. Restart AstrBot after fixing the environment to refresh the check. + */ + error?: string; + }; +}; + export type DataFileContentRequest = { content: string; expected_etag?: string; @@ -6296,9 +6324,13 @@ export type GetVersionData = { export type GetVersionResponses = { /** - * Standard AstrBot success response + * Version information with a backend runtime snapshot */ - 200: SuccessEnvelope; + 200: SuccessEnvelope & { + data?: { + runtime: RuntimeInfo; + }; + }; }; export type GetVersionResponse = GetVersionResponses[keyof GetVersionResponses]; diff --git a/dashboard/src/api/v1/types.ts b/dashboard/src/api/v1/types.ts index 3d1f6d5ec5..68b486ce01 100644 --- a/dashboard/src/api/v1/types.ts +++ b/dashboard/src/api/v1/types.ts @@ -50,12 +50,23 @@ export interface ProviderEmbeddingDimensionData { [key: string]: unknown; } +export interface RuntimeInfo { + os: string; + arch: string; + sandbox: { + backend: 'bubblewrap' | 'seatbelt' | null; + status: 'detected' | 'missing' | 'unavailable' | 'unsupported'; + error?: string; + }; +} + export interface VersionData { version?: string; dashboard_version?: string; change_pwd_hint?: boolean; md5_pwd_hint?: boolean; password_upgrade_required?: boolean; + runtime?: RuntimeInfo; [key: string]: unknown; } diff --git a/dashboard/src/components/shared/AstrBotConfigV4.vue b/dashboard/src/components/shared/AstrBotConfigV4.vue index aa5ffd99fc..99e164b5e6 100644 --- a/dashboard/src/components/shared/AstrBotConfigV4.vue +++ b/dashboard/src/components/shared/AstrBotConfigV4.vue @@ -255,7 +255,11 @@ function shouldShowSection() { class="config-item" > - + {{ getItemDescription(itemKey, itemMeta) }} @@ -277,7 +281,11 @@ function shouldShowSection() { - + + + + + + + + diff --git a/dashboard/src/components/shared/ProviderSelectMenu.vue b/dashboard/src/components/shared/ProviderSelectMenu.vue index bbb2cbb166..bbf3197940 100644 --- a/dashboard/src/components/shared/ProviderSelectMenu.vue +++ b/dashboard/src/components/shared/ProviderSelectMenu.vue @@ -29,16 +29,70 @@
- +
+ + + + + + {{ + sharedTm('providerSelector.allSources') + }} + + + {{ source.id }} + {{ source.apiBase }} + + + + + +
-