mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
refactor: seal local sandbox abstractions
This commit is contained in:
@@ -8,7 +8,6 @@ import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -27,6 +26,7 @@ from astrbot.core.computer.file_read_utils import (
|
||||
from astrbot.core.computer.process_sandbox import (
|
||||
SandboxProcess,
|
||||
SandboxSpec,
|
||||
SandboxTimeoutError,
|
||||
create_process_sandbox,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
@@ -935,46 +935,21 @@ class LocalPythonComponent(PythonComponent):
|
||||
)
|
||||
if sandboxed:
|
||||
sandbox = create_process_sandbox()
|
||||
sandbox_spec = SandboxSpec(
|
||||
workspace=working_dir,
|
||||
allow_network=allow_network,
|
||||
filesystem_scope=filesystem_scope,
|
||||
result = sandbox.run(
|
||||
[sys.executable, "-c", code],
|
||||
SandboxSpec(
|
||||
workspace=working_dir,
|
||||
allow_network=allow_network,
|
||||
filesystem_scope=filesystem_scope,
|
||||
),
|
||||
timeout=timeout,
|
||||
output_limit=_LOCAL_SANDBOX_MAX_OUTPUT_BYTES,
|
||||
discard_stdout=silent,
|
||||
)
|
||||
with (
|
||||
tempfile.TemporaryFile() as stdout_file,
|
||||
tempfile.TemporaryFile() as stderr_file,
|
||||
):
|
||||
result = sandbox.run(
|
||||
[sys.executable, "-c", code],
|
||||
sandbox_spec,
|
||||
timeout=timeout,
|
||||
stdout=subprocess.DEVNULL if silent else stdout_file,
|
||||
stderr=stderr_file,
|
||||
)
|
||||
if silent:
|
||||
stdout = ""
|
||||
stdout_limited = False
|
||||
else:
|
||||
stdout_file.seek(0)
|
||||
stdout_bytes = stdout_file.read(
|
||||
_LOCAL_SANDBOX_MAX_OUTPUT_BYTES + 1
|
||||
)
|
||||
stdout_limited = (
|
||||
len(stdout_bytes) > _LOCAL_SANDBOX_MAX_OUTPUT_BYTES
|
||||
)
|
||||
stdout = _decode_shell_output(
|
||||
stdout_bytes[:_LOCAL_SANDBOX_MAX_OUTPUT_BYTES]
|
||||
)
|
||||
stderr_file.seek(0)
|
||||
stderr_bytes = stderr_file.read(
|
||||
_LOCAL_SANDBOX_MAX_OUTPUT_BYTES + 1
|
||||
)
|
||||
stderr_limited = (
|
||||
len(stderr_bytes) > _LOCAL_SANDBOX_MAX_OUTPUT_BYTES
|
||||
)
|
||||
stderr = _decode_shell_output(
|
||||
stderr_bytes[:_LOCAL_SANDBOX_MAX_OUTPUT_BYTES]
|
||||
)
|
||||
stdout = _decode_shell_output(result.stdout)
|
||||
stderr = _decode_shell_output(result.stderr)
|
||||
stdout_limited = result.stdout_limited
|
||||
stderr_limited = result.stderr_limited
|
||||
else:
|
||||
run_command = [
|
||||
os.environ.get("PYTHON", sys.executable),
|
||||
@@ -1008,7 +983,7 @@ class LocalPythonComponent(PythonComponent):
|
||||
"error": execution_error,
|
||||
}
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
except (SandboxTimeoutError, subprocess.TimeoutExpired):
|
||||
return {
|
||||
"data": {
|
||||
"output": {"text": "", "images": []},
|
||||
@@ -1129,10 +1104,6 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
if before_context is not None:
|
||||
command.extend(["-B", str(before_context)])
|
||||
command.extend(["--", path or "."])
|
||||
run_kwargs: dict[str, Any] = {
|
||||
"capture_output": True,
|
||||
"timeout": 30,
|
||||
}
|
||||
sandbox_workspace: Path | None = None
|
||||
if sandboxed:
|
||||
if not sandbox_root:
|
||||
@@ -1152,11 +1123,15 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
workspace=sandbox_workspace,
|
||||
workspace_writable=False,
|
||||
),
|
||||
**run_kwargs,
|
||||
timeout=30,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(command, **run_kwargs)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
except (SandboxTimeoutError, subprocess.TimeoutExpired):
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
|
||||
@@ -2,9 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .base import ProcessSandbox, SandboxLimits, SandboxProcess, SandboxSpec
|
||||
from .bubblewrap import BubblewrapProcessSandbox
|
||||
from .seatbelt import SeatbeltProcessSandbox
|
||||
from .base import (
|
||||
ProcessSandbox,
|
||||
SandboxLimits,
|
||||
SandboxProcess,
|
||||
SandboxRunResult,
|
||||
SandboxSpec,
|
||||
SandboxTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
def create_process_sandbox() -> ProcessSandbox:
|
||||
@@ -17,18 +22,22 @@ def create_process_sandbox() -> ProcessSandbox:
|
||||
RuntimeError: If the current system has no Local sandbox implementation.
|
||||
"""
|
||||
if sys.platform.startswith("linux"):
|
||||
from .bubblewrap import BubblewrapProcessSandbox
|
||||
|
||||
return BubblewrapProcessSandbox()
|
||||
if sys.platform == "darwin":
|
||||
from .seatbelt import SeatbeltProcessSandbox
|
||||
|
||||
return SeatbeltProcessSandbox()
|
||||
raise RuntimeError(
|
||||
"The Local execution sandbox is only available on Linux and macOS."
|
||||
)
|
||||
raise RuntimeError("No Local process sandbox backend is available.")
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ProcessSandbox",
|
||||
"SandboxLimits",
|
||||
"SandboxProcess",
|
||||
"SandboxRunResult",
|
||||
"SandboxSpec",
|
||||
"SandboxTimeoutError",
|
||||
"create_process_sandbox",
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -66,6 +64,29 @@ class SandboxSpec:
|
||||
limits: SandboxLimits = field(default_factory=SandboxLimits)
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
@@ -129,14 +150,14 @@ class SandboxProcess(Protocol):
|
||||
class ProcessSandbox(ABC):
|
||||
"""Platform-independent launcher for restricted child processes."""
|
||||
|
||||
def build_command(
|
||||
def _prepare_command(
|
||||
self,
|
||||
argv: list[str],
|
||||
spec: SandboxSpec,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Validate common inputs and build a platform sandbox command.
|
||||
) -> 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.
|
||||
@@ -144,7 +165,7 @@ class ProcessSandbox(ABC):
|
||||
env: Additional environment variables exposed inside the sandbox.
|
||||
|
||||
Returns:
|
||||
Platform sandbox command and arguments.
|
||||
Normalized arguments, workspace, and environment values.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the workspace does not exist.
|
||||
@@ -172,33 +193,36 @@ class ProcessSandbox(ABC):
|
||||
raise ValueError(f"Invalid sandbox environment variable name: {key!r}.")
|
||||
normalized_env[key] = value
|
||||
|
||||
return self._build_command(sandbox_argv, workspace, spec, normalized_env)
|
||||
return sandbox_argv, workspace, normalized_env
|
||||
|
||||
@abstractmethod
|
||||
def run(
|
||||
self,
|
||||
argv: list[str],
|
||||
spec: SandboxSpec,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> subprocess.CompletedProcess[Any]:
|
||||
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.
|
||||
**kwargs: Extra keyword arguments passed to ``subprocess.run``.
|
||||
timeout: Maximum wall-clock runtime in seconds.
|
||||
output_limit: Maximum captured bytes for each output stream.
|
||||
discard_stdout: Whether to discard standard output.
|
||||
|
||||
Returns:
|
||||
Completed restricted process.
|
||||
Platform-independent process result.
|
||||
|
||||
Raises:
|
||||
SandboxTimeoutError: If the process exceeds ``timeout``.
|
||||
"""
|
||||
return subprocess.run(
|
||||
self.build_command(argv, spec, env=env),
|
||||
cwd=spec.workspace.resolve(),
|
||||
env={"PATH": os.defpath},
|
||||
**kwargs,
|
||||
)
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def spawn_shell(
|
||||
@@ -219,13 +243,3 @@ class ProcessSandbox(ABC):
|
||||
Running restricted process.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _build_command(
|
||||
self,
|
||||
argv: list[str],
|
||||
workspace: Path,
|
||||
spec: SandboxSpec,
|
||||
env: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Build a platform command after common input validation."""
|
||||
|
||||
@@ -3,16 +3,21 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +83,90 @@ class UnixSandboxProcess:
|
||||
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,
|
||||
@@ -106,6 +195,16 @@ class UnixProcessSandbox(ProcessSandbox):
|
||||
)
|
||||
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],
|
||||
|
||||
@@ -32,7 +32,6 @@ Local path resolution rule:
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -72,9 +71,6 @@ _SANDBOX_RUNTIME_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
}
|
||||
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
|
||||
_RACE_RESISTANT_LOCAL_ACCESS = sys.platform.startswith("linux") or (
|
||||
sys.platform == "darwin"
|
||||
)
|
||||
|
||||
|
||||
def _remote_basename(path: str) -> str:
|
||||
@@ -400,7 +396,7 @@ class FileReadTool(FunctionTool):
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
file_descriptor = None
|
||||
if restricted and _RACE_RESISTANT_LOCAL_ACCESS:
|
||||
if restricted:
|
||||
file_descriptor = open_file_in_allowed_roots(
|
||||
normalized_path,
|
||||
_read_allowed_roots(
|
||||
@@ -496,7 +492,7 @@ class FileWriteTool(FunctionTool):
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
file_descriptor = None
|
||||
if restricted and _RACE_RESISTANT_LOCAL_ACCESS:
|
||||
if restricted:
|
||||
file_descriptor = open_file_in_allowed_roots(
|
||||
normalized_path,
|
||||
_write_allowed_roots(
|
||||
@@ -609,7 +605,7 @@ class FileEditTool(FunctionTool):
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
file_descriptor = None
|
||||
if restricted and _RACE_RESISTANT_LOCAL_ACCESS:
|
||||
if restricted:
|
||||
file_descriptor = open_file_in_allowed_roots(
|
||||
normalized_path,
|
||||
_write_allowed_roots(
|
||||
@@ -868,7 +864,7 @@ class GrepTool(FunctionTool):
|
||||
)
|
||||
contents: list[str] = []
|
||||
for search_path in search_paths:
|
||||
sandboxed = restricted and _RACE_RESISTANT_LOCAL_ACCESS
|
||||
sandboxed = restricted
|
||||
if sandboxed:
|
||||
path_object = Path(search_path)
|
||||
matching_roots = [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import sys
|
||||
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.computer.process_sandbox import create_process_sandbox
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
|
||||
from astrbot.core.workspace import (
|
||||
@@ -162,14 +162,13 @@ def check_local_execution_permission(
|
||||
"for this role in AstrBot WebUI -> Config -> Normal Config -> AI -> "
|
||||
"Agent Computer Use -> Local Permission Policies."
|
||||
)
|
||||
if policy.requires_sandbox and not (
|
||||
sys.platform.startswith("linux") or sys.platform == "darwin"
|
||||
):
|
||||
return policy, (
|
||||
"error: Permission denied. Restricted Local execution is only supported "
|
||||
"on Linux with bubblewrap or macOS with Seatbelt. To use restricted "
|
||||
"execution on this platform, select `Third-party sandbox` under AstrBot "
|
||||
"WebUI -> Config -> Normal Config -> AI -> Agent Computer Use -> "
|
||||
"Computer Use Runtime."
|
||||
)
|
||||
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 -> "
|
||||
"Normal Config -> AI -> Agent Computer Use -> Computer Use Runtime."
|
||||
)
|
||||
return policy, None
|
||||
|
||||
@@ -36,9 +36,7 @@ def _make_context(
|
||||
if local_permissions is not None:
|
||||
provider_settings["computer_use_local_permissions"] = local_permissions
|
||||
config_holder = SimpleNamespace(
|
||||
get_config=lambda umo=None: {
|
||||
"provider_settings": provider_settings
|
||||
}
|
||||
get_config=lambda umo=None: {"provider_settings": provider_settings}
|
||||
)
|
||||
event = SimpleNamespace(
|
||||
role=role,
|
||||
@@ -543,6 +541,32 @@ async def test_restricted_local_member_rejects_workspace_hardlink_alias(
|
||||
assert outside_file.read_text(encoding="utf-8") == "outside-secret\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_local_write_fails_closed_without_safe_file_access(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
):
|
||||
workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
|
||||
|
||||
def unavailable_file_access(*_args, **_kwargs):
|
||||
raise RuntimeError("Restricted file access is unavailable.")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fs_tools,
|
||||
"open_file_in_allowed_roots",
|
||||
unavailable_file_access,
|
||||
)
|
||||
|
||||
result = await fs_tools.FileWriteTool().call(
|
||||
_make_context(role="member"),
|
||||
path="blocked.txt",
|
||||
content="must not be written\n",
|
||||
)
|
||||
|
||||
assert "Restricted file access is unavailable" in result
|
||||
assert not (workspace / "blocked.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
not (sys.platform.startswith("linux") or sys.platform == "darwin"),
|
||||
@@ -674,10 +698,6 @@ async def test_restricted_local_edit_reuses_first_opened_file(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
not (sys.platform.startswith("linux") or sys.platform == "darwin"),
|
||||
reason="Restricted Local execution sandbox is POSIX-only.",
|
||||
)
|
||||
async def test_restricted_local_grep_requests_read_only_os_sandbox(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
|
||||
@@ -139,10 +139,13 @@ def test_local_permission_policy_denies_disabled_execution():
|
||||
assert "Local Permission Policies" in error
|
||||
|
||||
|
||||
def test_windows_local_execution_requires_a_full_trust_policy(monkeypatch):
|
||||
def test_unavailable_local_sandbox_requires_a_full_trust_policy(monkeypatch):
|
||||
from astrbot.core.tools.computer_tools import util as computer_util
|
||||
|
||||
monkeypatch.setattr(computer_util.sys, "platform", "win32")
|
||||
def unavailable_sandbox():
|
||||
raise RuntimeError("No Local process sandbox backend is available.")
|
||||
|
||||
monkeypatch.setattr(computer_util, "create_process_sandbox", unavailable_sandbox)
|
||||
restricted = {
|
||||
"member": {
|
||||
"allow_execution": True,
|
||||
@@ -168,7 +171,7 @@ def test_windows_local_execution_requires_a_full_trust_policy(monkeypatch):
|
||||
)
|
||||
|
||||
assert restricted_error is not None
|
||||
assert "only supported on Linux" in restricted_error
|
||||
assert "No Local process sandbox backend" in restricted_error
|
||||
assert "Third-party sandbox" in restricted_error
|
||||
assert "Computer Use Runtime" in restricted_error
|
||||
assert full_error is None
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.computer import process_sandbox
|
||||
from astrbot.core.computer.booters import local as local_booter
|
||||
from astrbot.core.computer.booters.local import LocalFileSystemComponent
|
||||
|
||||
@@ -230,19 +231,19 @@ def test_local_file_system_component_runs_restricted_search_in_read_only_sandbox
|
||||
sandbox_calls = []
|
||||
|
||||
class FakeSandbox:
|
||||
def run(self, argv, spec, *, env=None, **kwargs):
|
||||
def run(self, argv, spec, *, env=None, timeout=None, **kwargs):
|
||||
sandbox_calls.append(
|
||||
{
|
||||
"argv": argv,
|
||||
"workspace": spec.workspace,
|
||||
"env": env,
|
||||
"workspace_writable": spec.workspace_writable,
|
||||
"timeout": timeout,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
return process_sandbox.SandboxRunResult(
|
||||
returncode=0,
|
||||
stdout=b"target.txt:1:needle\n",
|
||||
stderr=b"",
|
||||
)
|
||||
@@ -294,7 +295,8 @@ def test_local_file_system_component_runs_restricted_search_in_read_only_sandbox
|
||||
"workspace": tmp_path,
|
||||
"env": None,
|
||||
"workspace_writable": False,
|
||||
"kwargs": {"capture_output": True, "timeout": 30},
|
||||
"timeout": 30,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from astrbot.core.computer.booters.local import (
|
||||
LocalPythonComponent,
|
||||
LocalShellComponent,
|
||||
)
|
||||
from astrbot.core.computer.process_sandbox.unix import UnixProcessSandbox
|
||||
|
||||
|
||||
class _FakePopen:
|
||||
@@ -68,7 +69,9 @@ def _build_sandbox_command(
|
||||
Returns:
|
||||
Platform sandbox command and arguments.
|
||||
"""
|
||||
return process_sandbox.create_process_sandbox().build_command(
|
||||
sandbox = process_sandbox.create_process_sandbox()
|
||||
assert isinstance(sandbox, UnixProcessSandbox)
|
||||
return sandbox.build_command(
|
||||
argv,
|
||||
process_sandbox.SandboxSpec(
|
||||
workspace=workspace,
|
||||
@@ -278,7 +281,7 @@ def test_process_sandbox_selects_system_implementation(
|
||||
def test_process_sandbox_fails_closed_on_unsupported_system(monkeypatch):
|
||||
monkeypatch.setattr(process_sandbox.sys, "platform", "win32")
|
||||
|
||||
with pytest.raises(RuntimeError, match="only available on Linux and macOS"):
|
||||
with pytest.raises(RuntimeError, match="No Local process sandbox backend"):
|
||||
process_sandbox.create_process_sandbox()
|
||||
|
||||
|
||||
@@ -1017,6 +1020,27 @@ async def test_sandboxed_local_python_caps_returned_output(monkeypatch, tmp_path
|
||||
assert result["data"]["error"] == "Execution output exceeded 5 bytes."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandboxed_local_python_handles_backend_timeout(monkeypatch, tmp_path):
|
||||
class FakeSandbox:
|
||||
def run(self, *_args, **_kwargs):
|
||||
raise process_sandbox.SandboxTimeoutError
|
||||
|
||||
monkeypatch.setattr(
|
||||
local_booter,
|
||||
"create_process_sandbox",
|
||||
lambda: FakeSandbox(),
|
||||
)
|
||||
|
||||
result = await LocalPythonComponent().exec(
|
||||
"while True: pass",
|
||||
cwd=str(tmp_path),
|
||||
sandboxed=True,
|
||||
)
|
||||
|
||||
assert result["data"]["error"] == "Execution timed out."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_managed_shell_prefers_pwsh_when_available(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
|
||||
@@ -277,11 +277,9 @@ async def test_local_shell_tools_fail_closed_without_sender_identity(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("platform_name", ["linux", "darwin"])
|
||||
async def test_local_member_shell_uses_platform_sandbox(
|
||||
async def test_local_member_shell_uses_sandbox_backend(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
platform_name,
|
||||
):
|
||||
from astrbot.core.tools.computer_tools import shell as shell_tools
|
||||
from astrbot.core.tools.computer_tools import util as computer_util
|
||||
@@ -328,7 +326,7 @@ async def test_local_member_shell_uses_platform_sandbox(
|
||||
async def fake_get_booter(context, session_id):
|
||||
return booter
|
||||
|
||||
monkeypatch.setattr(computer_util.sys, "platform", platform_name)
|
||||
monkeypatch.setattr(computer_util, "create_process_sandbox", object)
|
||||
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
|
||||
monkeypatch.setattr(
|
||||
shell_tools,
|
||||
@@ -385,11 +383,14 @@ async def test_local_member_shell_is_denied_without_supported_sandbox(monkeypatc
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr(computer_util.sys, "platform", "win32")
|
||||
def unavailable_sandbox():
|
||||
raise RuntimeError("No Local process sandbox backend is available.")
|
||||
|
||||
monkeypatch.setattr(computer_util, "create_process_sandbox", unavailable_sandbox)
|
||||
|
||||
result = await LocalExecuteShellTool().call(wrapper, command="pwd")
|
||||
|
||||
assert "Linux with bubblewrap or macOS with Seatbelt" in result
|
||||
assert "No Local process sandbox backend" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -81,11 +81,9 @@ async def test_local_python_tool_uses_session_workspace(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("platform_name", ["linux", "darwin"])
|
||||
async def test_local_member_python_uses_platform_sandbox(
|
||||
async def test_local_member_python_uses_sandbox_backend(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
platform_name,
|
||||
):
|
||||
"""Local member Python execution should require an OS sandbox."""
|
||||
from astrbot.core.tools.computer_tools import util as computer_util
|
||||
@@ -99,7 +97,7 @@ async def test_local_member_python_uses_platform_sandbox(
|
||||
"astrbot.core.tools.computer_tools.python.get_local_booter",
|
||||
lambda: SimpleNamespace(python=local_python),
|
||||
)
|
||||
monkeypatch.setattr(computer_util.sys, "platform", platform_name)
|
||||
monkeypatch.setattr(computer_util, "create_process_sandbox", object)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.tools.computer_tools.python.workspace_root_for_context",
|
||||
AsyncMock(return_value=tmp_path),
|
||||
@@ -140,10 +138,13 @@ async def test_local_member_python_uses_platform_sandbox(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_member_python_is_denied_without_supported_sandbox(monkeypatch):
|
||||
"""Local member Python execution should fail closed on other platforms."""
|
||||
"""Local member Python execution should fail without a sandbox backend."""
|
||||
from astrbot.core.tools.computer_tools import util as computer_util
|
||||
|
||||
monkeypatch.setattr(computer_util.sys, "platform", "win32")
|
||||
def unavailable_sandbox():
|
||||
raise RuntimeError("No Local process sandbox backend is available.")
|
||||
|
||||
monkeypatch.setattr(computer_util, "create_process_sandbox", unavailable_sandbox)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.tools.computer_tools.python.get_local_booter",
|
||||
lambda: pytest.fail("Local Python must not start without an OS sandbox"),
|
||||
@@ -169,4 +170,4 @@ async def test_local_member_python_is_denied_without_supported_sandbox(monkeypat
|
||||
|
||||
result = await LocalPythonTool().call(context, code="print('ok')")
|
||||
|
||||
assert "Linux with bubblewrap or macOS with Seatbelt" in result
|
||||
assert "No Local process sandbox backend" in result
|
||||
|
||||
Reference in New Issue
Block a user