mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-13 08:28:44 +08:00
Merge branch 'dev' of https://github.com/AstrBotDevs/AstrBot into dev
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import shlex
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
|
||||
def _decode_shell_output(output: bytes | None) -> str:
|
||||
if output is None:
|
||||
return ""
|
||||
|
||||
preferred = locale.getpreferredencoding(False) or "utf-8"
|
||||
try:
|
||||
return output.decode("utf-8")
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
return output.decode(preferred)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return output.decode("utf-8", errors="replace")
|
||||
|
||||
@dataclass
|
||||
class BwrapConfig:
|
||||
workspace_dir: str
|
||||
ro_binds: list[str] = field(default_factory=list)
|
||||
rw_binds: list[str] = field(default_factory=list)
|
||||
share_net: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
# Merge default required system binds with any additional ro_binds passed
|
||||
default_ro = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/opt"]
|
||||
for p in default_ro:
|
||||
if p not in self.ro_binds:
|
||||
self.ro_binds.append(p)
|
||||
|
||||
def build_bwrap_cmd(config: BwrapConfig, script_cmd: list[str]) -> list[str]:
|
||||
"""Helper to build a bubblewrap command."""
|
||||
cmd = ["bwrap"]
|
||||
|
||||
if not config.share_net:
|
||||
cmd.append("--unshare-net")
|
||||
|
||||
# Bind paths to itself so paths match
|
||||
for path in config.ro_binds:
|
||||
if os.path.exists(path):
|
||||
cmd.extend(["--ro-bind", path, path])
|
||||
|
||||
for path in config.rw_binds:
|
||||
# Avoid bind mounting dangerous host paths
|
||||
if path == "/" or path.startswith("/root"):
|
||||
continue
|
||||
if os.path.exists(path):
|
||||
cmd.extend(["--bind", path, path])
|
||||
|
||||
# Make system binds the last to avoid issues about ro `/`
|
||||
cmd.extend([
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
"--die-with-parent",
|
||||
"--dir", "/tmp",
|
||||
"--dir", "/var/tmp",
|
||||
"--proc", "/proc",
|
||||
"--dev", "/dev",
|
||||
"--bind", config.workspace_dir, config.workspace_dir,
|
||||
])
|
||||
|
||||
cmd.extend(["--"])
|
||||
cmd.extend(script_cmd)
|
||||
return cmd
|
||||
|
||||
@dataclass
|
||||
class BwrapShellComponent(ShellComponent):
|
||||
config: BwrapConfig
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: int | None = 30,
|
||||
shell: bool = True,
|
||||
background: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||
|
||||
working_dir = cwd if cwd else self.config.workspace_dir
|
||||
|
||||
# Use /bin/sh -c to run the evaluated command
|
||||
# The command must be run inside bwrap
|
||||
script_cmd = ["/bin/sh", "-c", command] if shell else shlex.split(command)
|
||||
bwrap_cmd = build_bwrap_cmd(self.config, script_cmd)
|
||||
|
||||
if background:
|
||||
proc = subprocess.Popen(
|
||||
bwrap_cmd,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
||||
|
||||
result = subprocess.run(
|
||||
bwrap_cmd,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
)
|
||||
return {
|
||||
"stdout": _decode_shell_output(result.stdout),
|
||||
"stderr": _decode_shell_output(result.stderr),
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
@dataclass
|
||||
class BwrapPythonComponent(PythonComponent):
|
||||
config: BwrapConfig
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
code: str,
|
||||
kernel_id: str | None = None,
|
||||
timeout: int = 30,
|
||||
silent: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
bwrap_cmd = build_bwrap_cmd(self.config, [os.environ.get("PYTHON", "python3"), "-c", code])
|
||||
try:
|
||||
result = subprocess.run(
|
||||
bwrap_cmd,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
stdout = "" if silent else result.stdout
|
||||
return {
|
||||
"stdout": stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return {
|
||||
"stdout": e.stdout.decode() if isinstance(e.stdout, bytes) else str(e.stdout or ""),
|
||||
"stderr": f"Execution timed out after {timeout} seconds.",
|
||||
"exit_code": 1,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
@dataclass
|
||||
class HostBackedFileSystemComponent(FileSystemComponent):
|
||||
"""File operations happen safely on host mapping to workspace, making I/O extremely fast."""
|
||||
workspace_dir: str
|
||||
|
||||
def _safe_path(self, path: str) -> str:
|
||||
# Simply maps it. In a stricter implementation, we could verify it's inside workspace_dir.
|
||||
# But for this implementation, we trust the agent or restrict to workspace_dir.
|
||||
if not path.startswith("/"):
|
||||
path = os.path.join(self.workspace_dir, path)
|
||||
return path
|
||||
|
||||
async def create_file(self, path: str, content: str = "", mode: int = 0o644) -> dict[str, Any]:
|
||||
p = self._safe_path(path)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
os.chmod(p, mode)
|
||||
return {"success": True, "path": p}
|
||||
|
||||
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||
p = self._safe_path(path)
|
||||
try:
|
||||
with open(p, "r", encoding=encoding) as f:
|
||||
content = f.read()
|
||||
return {"success": True, "content": content}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def write_file(self, path: str, content: str, mode: str = "w", encoding: str = "utf-8") -> dict[str, Any]:
|
||||
p = self._safe_path(path)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
try:
|
||||
with open(p, mode, encoding=encoding) as f:
|
||||
f.write(content)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def delete_file(self, path: str) -> dict[str, Any]:
|
||||
p = self._safe_path(path)
|
||||
try:
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p)
|
||||
else:
|
||||
os.remove(p)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def list_dir(self, path: str = ".", show_hidden: bool = False) -> dict[str, Any]:
|
||||
p = self._safe_path(path)
|
||||
try:
|
||||
items = os.listdir(p)
|
||||
if not show_hidden:
|
||||
items = [item for item in items if not item.startswith(".")]
|
||||
return {"success": True, "items": items}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "items": []}
|
||||
|
||||
class BwrapBooter(ComputerBooter):
|
||||
def __init__(self, rw_binds: list[str] = None, ro_binds: list[str] = None):
|
||||
self._rw_binds = rw_binds or []
|
||||
self._ro_binds = ro_binds or []
|
||||
self._fs: HostBackedFileSystemComponent | None = None
|
||||
self._python: BwrapPythonComponent | None = None
|
||||
self._shell: BwrapShellComponent | None = None
|
||||
self.config: BwrapConfig | None = None
|
||||
|
||||
@property
|
||||
def fs(self) -> FileSystemComponent:
|
||||
return self._fs
|
||||
|
||||
@property
|
||||
def python(self) -> PythonComponent:
|
||||
return self._python
|
||||
|
||||
@property
|
||||
def shell(self) -> ShellComponent:
|
||||
return self._shell
|
||||
|
||||
@property
|
||||
def capabilities(self) -> tuple[str, ...]:
|
||||
return ("python", "shell", "filesystem")
|
||||
|
||||
async def boot(self, session_id: str) -> None:
|
||||
workspace_dir = os.path.join(get_astrbot_temp_path(), f"sandbox_workspace_{session_id}")
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
|
||||
self.config = BwrapConfig(
|
||||
workspace_dir=os.path.abspath(workspace_dir),
|
||||
rw_binds=self._rw_binds,
|
||||
ro_binds=self._ro_binds
|
||||
)
|
||||
self._fs = HostBackedFileSystemComponent(self.config.workspace_dir)
|
||||
self._python = BwrapPythonComponent(self.config)
|
||||
self._shell = BwrapShellComponent(self.config)
|
||||
if not await self.available():
|
||||
raise RuntimeError(
|
||||
"BubbleWrap sandbox unavailable on current machine for no bwrap executable.")
|
||||
test_shl = await self._shell.exec(command = "ls > /dev/null")
|
||||
if test_shl["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
'''BubbleWrap sandbox fails to exec test shell command "ls > /dev/null" with stderr:
|
||||
{}'''.format(test_shl["stderr"]))
|
||||
test_py = await self._python.exec(code = "print('Yes')")
|
||||
if test_py["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
'''BubbleWrap sandbox fails to exec test python code "print('Yes')" with stderr:
|
||||
{}'''.format(test_py["stderr"]))
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self.config and os.path.exists(self.config.workspace_dir):
|
||||
shutil.rmtree(self.config.workspace_dir, ignore_errors=True)
|
||||
|
||||
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||
if not self._fs:
|
||||
return {"success": False, "error": "Not booted"}
|
||||
target = os.path.join(self.config.workspace_dir, file_name)
|
||||
try:
|
||||
shutil.copy2(path, target)
|
||||
return {"success": True, "file_path": target}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def download_file(self, remote_path: str, local_path: str) -> None:
|
||||
if not self._fs:
|
||||
return
|
||||
if not remote_path.startswith("/"):
|
||||
remote_path = os.path.join(self.config.workspace_dir, remote_path)
|
||||
shutil.copy2(remote_path, local_path)
|
||||
|
||||
async def available(self) -> bool:
|
||||
if sys.platform == "win32":
|
||||
return False
|
||||
if shutil.which("bwrap") is None:
|
||||
return False
|
||||
return True
|
||||
@@ -506,6 +506,12 @@ async def get_booter(
|
||||
from .booters.boxlite import BoxliteBooter
|
||||
|
||||
client = BoxliteBooter()
|
||||
elif booter_type == "bwrap":
|
||||
from .booters.bwrap import BwrapBooter
|
||||
|
||||
rw_binds = sandbox_cfg.get("bwrap_rw_binds", [])
|
||||
ro_binds = sandbox_cfg.get("bwrap_ro_binds", [])
|
||||
client = BwrapBooter(rw_binds=rw_binds, ro_binds=ro_binds)
|
||||
else:
|
||||
raise ValueError(f"Unknown booter type: {booter_type}")
|
||||
|
||||
|
||||
@@ -190,6 +190,8 @@ DEFAULT_CONFIG = {
|
||||
"shipyard_neo_access_token": "",
|
||||
"shipyard_neo_profile": "python-default",
|
||||
"shipyard_neo_ttl": 3600,
|
||||
"bwrap_rw_binds": [],
|
||||
"bwrap_ro_binds": [],
|
||||
},
|
||||
"image_compress_enabled": True,
|
||||
"image_compress_options": {
|
||||
@@ -3163,12 +3165,32 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.sandbox.booter": {
|
||||
"description": "沙箱环境驱动器",
|
||||
"type": "string",
|
||||
"options": ["shipyard_neo", "shipyard"],
|
||||
"labels": ["Shipyard Neo", "Shipyard"],
|
||||
"options": ["shipyard_neo", "shipyard", "bwrap"],
|
||||
"labels": ["Shipyard Neo", "Shipyard", "Bubblewrap"],
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.bwrap_rw_binds": {
|
||||
"description": "Bubblewrap Read & Write Binds",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "Bubblewrap 沙箱额外的读写挂载目录(回车添加,可添加多个。默认 /tmp, /var/tmp 已经是读写。",
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "bwrap",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.bwrap_ro_binds": {
|
||||
"description": "Bubblewrap Read Only Binds",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "Bubblewrap 沙箱额外的只读挂载目录(回车添加,可添加多个。宿主机系统目录/usr, /etc, /opt默认只读挂载于沙箱内的相同位置)。",
|
||||
"condition": {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "bwrap",
|
||||
},
|
||||
},
|
||||
"provider_settings.sandbox.shipyard_neo_endpoint": {
|
||||
"description": "Shipyard Neo API Endpoint",
|
||||
"type": "string",
|
||||
|
||||
@@ -162,6 +162,14 @@
|
||||
"booter": {
|
||||
"description": "Sandbox Environment Driver"
|
||||
},
|
||||
"bwrap_rw_binds": {
|
||||
"description": "Bubblewrap Read & Write Binds",
|
||||
"hint": "Additional read-write mount directories for the Bubblewrap sandbox (press Enter to add, multiple can be added. /tmp and /var/tmp are read-write by default)."
|
||||
},
|
||||
"bwrap_ro_binds": {
|
||||
"description": "Bubblewrap Read Only Binds",
|
||||
"hint": "Additional read-only mount directories for the Bubblewrap sandbox (press Enter to add, multiple can be added. Host system directories /usr, /etc, /opt are mounted read-only at the same location by default)."
|
||||
},
|
||||
"shipyard_neo_endpoint": {
|
||||
"description": "Shipyard Neo API Endpoint",
|
||||
"hint": "Bay API address, default http://127.0.0.1:8114."
|
||||
@@ -193,6 +201,10 @@
|
||||
"shipyard_max_sessions": {
|
||||
"description": "Shipyard Max Sessions",
|
||||
"hint": "Maximum number of Shipyard sessions an instance can handle."
|
||||
},
|
||||
"bwrap_rw_binds": {
|
||||
"description": "Bubblewrap RW Binds",
|
||||
"hint": "Extra read-write mount directories for Bubblewrap sandbox (press Enter to add, multiple allowed. /tmp and /var/tmp are RW by default, and the entire host system is mounted RO in the same location by default)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,14 @@
|
||||
"booter": {
|
||||
"description": "Драйвер среды песочницы"
|
||||
},
|
||||
"bwrap_rw_binds": {
|
||||
"description": "Дополнительные папки чтения-записи Bubblewrap (RW)",
|
||||
"hint": "Дополнительные папки для монтирования на чтение и запись в песочнице Bubblewrap (нажмите Enter для добавления. По умолчанию /tmp и /var/tmp уже доступны для записи)."
|
||||
},
|
||||
"bwrap_ro_binds": {
|
||||
"description": "Дополнительные папки только для чтения Bubblewrap (RO)",
|
||||
"hint": "Дополнительные папки для монтирования только для чтения в песочнице Bubblewrap (нажмите Enter для добавления. По умолчанию /usr, /etc и /opt уже смонтированы только для чтения)."
|
||||
},
|
||||
"shipyard_neo_endpoint": {
|
||||
"description": "Эндпоинт Shipyard Neo API",
|
||||
"hint": "Адрес Bay API, по умолчанию http://127.0.0.1:8114."
|
||||
@@ -193,6 +201,10 @@
|
||||
"shipyard_max_sessions": {
|
||||
"description": "Макс. количество сессий Shipyard",
|
||||
"hint": "Максимальное количество сессий Shipyard, которое может поддерживать экземпляр."
|
||||
},
|
||||
"bwrap_rw_binds": {
|
||||
"description": "Bubblewrap RW Binds",
|
||||
"hint": "Дополнительные каталоги монтирования для чтения/записи (нажмите Enter для добавления, можно добавить несколько; по умолчанию /tmp и /var/tmp доступны для записи, а вся система хоста монтируется в режиме RO в том же расположении)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,14 @@
|
||||
"booter": {
|
||||
"description": "沙箱环境驱动器"
|
||||
},
|
||||
"bwrap_rw_binds": {
|
||||
"description": "Bubblewrap 额外读写挂载目录",
|
||||
"hint": "Bubblewrap 沙箱额外的读写挂载目录(回车添加,可添加多个。默认 /tmp, /var/tmp 已经是读写。"
|
||||
},
|
||||
"bwrap_ro_binds": {
|
||||
"description": "Bubblewrap 额外只读挂载目录",
|
||||
"hint": "Bubblewrap 沙箱额外的只读挂载目录(回车添加,可添加多个。宿主机系统目录/usr, /etc, /opt默认只读挂载于沙箱内的相同位置)。"
|
||||
},
|
||||
"shipyard_neo_endpoint": {
|
||||
"description": "Shipyard Neo API Endpoint",
|
||||
"hint": "Shipyard Neo(Bay) 服务的 API 地址,默认 http://127.0.0.1:8114。"
|
||||
@@ -195,6 +203,10 @@
|
||||
"shipyard_max_sessions": {
|
||||
"description": "Shipyard Ship 会话复用上限",
|
||||
"hint": "决定了一个实例承载的最大会话数量。"
|
||||
},
|
||||
"bwrap_rw_binds": {
|
||||
"description": "Bubblewrap 读写挂载目录",
|
||||
"hint": "额外的读写挂载目录(回车添加,可添加多个。默认 /tmp, /var/tmp 已经是读写,整个宿主机系统默认是只读挂载于沙箱内的相同位置)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ filesystem operations, Python execution, shell execution, and security restricti
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -19,6 +21,15 @@ from astrbot.core.computer.booters.local import (
|
||||
_is_safe_command,
|
||||
)
|
||||
|
||||
from astrbot.core.computer.booters.bwrap import (
|
||||
BwrapBooter,
|
||||
BwrapConfig,
|
||||
build_bwrap_cmd,
|
||||
HostBackedFileSystemComponent,
|
||||
BwrapPythonComponent,
|
||||
BwrapShellComponent,
|
||||
)
|
||||
|
||||
|
||||
class TestLocalBooterInit:
|
||||
"""Tests for LocalBooter initialization."""
|
||||
@@ -886,3 +897,143 @@ class TestSyncSkillsToSandbox:
|
||||
):
|
||||
# Should not raise
|
||||
await computer_client._sync_skills_to_sandbox(mock_booter)
|
||||
|
||||
class TestBwrapConfigAndBuilder:
|
||||
def test_bwrap_config_defaults(self):
|
||||
config = BwrapConfig(workspace_dir="/tmp/test")
|
||||
# System defaults should be merged
|
||||
assert "/usr" in config.ro_binds
|
||||
assert "/etc" in config.ro_binds
|
||||
|
||||
# Test custom additions
|
||||
config2 = BwrapConfig(workspace_dir="/tmp/test", ro_binds=["/custom"])
|
||||
assert "/custom" in config2.ro_binds
|
||||
assert "/usr" in config2.ro_binds
|
||||
|
||||
def test_build_bwrap_cmd(self):
|
||||
config = BwrapConfig(workspace_dir="/tmp/test", rw_binds=[], ro_binds=[])
|
||||
cmd = build_bwrap_cmd(config, ["echo", "hello"])
|
||||
|
||||
assert "bwrap" in cmd
|
||||
assert "--unshare-pid" in cmd
|
||||
assert "--bind" in cmd
|
||||
assert "/tmp/test" in cmd
|
||||
assert "--" in cmd
|
||||
assert "echo" == cmd[-2]
|
||||
assert "hello" == cmd[-1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bwrap") is None, reason="bwrap is not installed")
|
||||
class TestBwrapBooterLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_boot(self):
|
||||
booter = BwrapBooter()
|
||||
await booter.boot("test_session_123")
|
||||
assert booter.config is not None
|
||||
assert os.path.exists(booter.config.workspace_dir)
|
||||
await booter.shutdown()
|
||||
assert not os.path.exists(booter.config.workspace_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_available(self):
|
||||
booter = BwrapBooter()
|
||||
avail = await booter.available()
|
||||
assert avail is True # We skipped if no bwrap installed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_upload_download(self, tmp_path):
|
||||
booter = BwrapBooter()
|
||||
await booter.boot("test_session_io")
|
||||
|
||||
# Test upload
|
||||
host_file = tmp_path / "test_upload.txt"
|
||||
host_file.write_text("hello bwrap")
|
||||
|
||||
res = await booter.upload_file(str(host_file), "target.txt")
|
||||
assert res.get("success") is True
|
||||
|
||||
# Verify it exists in workspace
|
||||
target_path = os.path.join(booter.config.workspace_dir, "target.txt")
|
||||
assert os.path.exists(target_path)
|
||||
|
||||
# Test download
|
||||
dl_path = tmp_path / "downloaded.txt"
|
||||
await booter.download_file("target.txt", str(dl_path))
|
||||
assert dl_path.exists()
|
||||
assert dl_path.read_text() == "hello bwrap"
|
||||
|
||||
await booter.shutdown()
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bwrap") is None, reason="bwrap is not installed")
|
||||
class TestBwrapShellComponent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_shell_exec(self):
|
||||
booter = BwrapBooter()
|
||||
await booter.boot("test_shell")
|
||||
res = await booter.shell.exec("echo 'hello bwrap'")
|
||||
assert res["exit_code"] == 0
|
||||
assert "hello bwrap" in res["stdout"]
|
||||
await booter.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_shell_ro_slash(self):
|
||||
# Testing the system-first + ro root order you mentioned
|
||||
booter = BwrapBooter(ro_binds=["/"])
|
||||
await booter.boot("test_shell_ro")
|
||||
|
||||
# Will it write to /dev/null correctly despite ro /?
|
||||
res = await booter.shell.exec("echo xxx > /dev/null && echo success")
|
||||
assert res["exit_code"] == 0
|
||||
assert "success" in res["stdout"]
|
||||
|
||||
# Will it fail to write to ro /tmp?
|
||||
res2 = await booter.shell.exec("echo yyy > /tmp/test_write.txt", shell=True)
|
||||
# /tmp in bwrap is tmpfs by default from our flags, so this might actually succeed.
|
||||
# Let's try writing to /usr instead
|
||||
res3 = await booter.shell.exec("echo yyy > /usr/test_write.txt", shell=True)
|
||||
assert res3["exit_code"] != 0
|
||||
|
||||
await booter.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bwrap") is None, reason="bwrap is not installed")
|
||||
class TestBwrapPythonComponent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_python_exec(self):
|
||||
booter = BwrapBooter()
|
||||
await booter.boot("test_python")
|
||||
res = await booter.python.exec("print('hello python from bwrap')")
|
||||
assert res["exit_code"] == 0
|
||||
assert "hello python from bwrap" in res["stdout"]
|
||||
await booter.shutdown()
|
||||
|
||||
|
||||
class TestHostBackedFileSystemComponent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fs_create_read_delete(self, tmp_path):
|
||||
fs = HostBackedFileSystemComponent(str(tmp_path))
|
||||
|
||||
# create
|
||||
res = await fs.create_file("test.txt", "hello fs")
|
||||
assert res["success"] is True
|
||||
assert (tmp_path / "test.txt").exists()
|
||||
|
||||
# read
|
||||
res_read = await fs.read_file("test.txt")
|
||||
assert res_read["success"] is True
|
||||
assert res_read["content"] == "hello fs"
|
||||
|
||||
# write
|
||||
res_write = await fs.write_file("test.txt", "updated fs")
|
||||
assert res_write["success"] is True
|
||||
assert (tmp_path / "test.txt").read_text() == "updated fs"
|
||||
|
||||
# list
|
||||
res_list = await fs.list_dir()
|
||||
assert res_list["success"] is True
|
||||
assert "test.txt" in res_list["items"]
|
||||
|
||||
# delete
|
||||
res_del = await fs.delete_file("test.txt")
|
||||
assert res_del["success"] is True
|
||||
assert not (tmp_path / "test.txt").exists()
|
||||
|
||||
Reference in New Issue
Block a user