Files
AstrBot/tests/unit/test_func_tool_manager.py
エイカク cb5c172e69 feat: add CUA computer-use sandbox support (#7828)
* feat: add CUA computer-use sandbox support

* fix: add CUA config metadata translations

* fix: address CUA sandbox review feedback

* fix: default CUA sandbox to local mode

* fix: harden CUA SDK method compatibility

* fix: harden CUA GUI and permission handling

* fix: refine CUA capability and shell handling

* fix: avoid inline CUA screenshot image results by default

* fix: guide CUA browser startup workflow

* feat: add CUA browser and key press tools

* fix: launch CUA browser as sandbox user

* fix: stabilize CUA browser screenshots

* fix: simplify CUA browser launch command

* fix: remove CUA open browser tool

* fix: align CUA desktop control guidance

* fix: harden CUA shell background handling

* fix: harden CUA runtime adapters

* fix: surface CUA filesystem failures

* fix: clarify CUA shell fallback support

* fix: harden CUA shell helpers

* fix: guard CUA file fallbacks

* fix: redact sensitive config log paths

* fix: guard CUA download fallback

* test: cover CUA GUI and shell env wiring

* fix: preserve CUA command result output

* fix: normalize CUA return codes

* fix: preserve foreground shell behavior

* fix: clean up failed CUA boots

* docs: add CUA sandbox runtime guide

* test: cover CUA GUI tool registration

* refactor: simplify CUA fallback handling

* refactor: simplify CUA shell helpers

* test: cover CUA screenshot result shapes
2026-04-28 01:40:14 +09:00

336 lines
9.8 KiB
Python

import json
import pytest
from astrbot.core import sp
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.tools.web_search_tools import (
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
)
def test_get_builtin_tool_by_class_returns_cached_instance():
manager = FunctionToolManager()
tool_by_class = manager.get_builtin_tool(SendMessageToUserTool)
tool_by_name = manager.get_builtin_tool("send_message_to_user")
assert tool_by_class is tool_by_name
assert manager.get_func("send_message_to_user") is tool_by_class
assert tool_by_class.name == "send_message_to_user"
def test_builtin_tool_ignores_inactivated_llm_tools():
manager = FunctionToolManager()
sp.put(
"inactivated_llm_tools",
["send_message_to_user"],
scope="global",
scope_id="global",
)
try:
tool = manager.get_builtin_tool(SendMessageToUserTool)
assert tool.active is True
finally:
sp.put("inactivated_llm_tools", [], scope="global", scope_id="global")
def test_computer_tools_are_registered_as_builtin_tools():
manager = FunctionToolManager()
tool = manager.get_builtin_tool(ExecuteShellTool)
assert tool.name == "astrbot_execute_shell"
assert tool.parameters["properties"]["background"]["default"] is False
assert manager.is_builtin_tool("astrbot_execute_shell") is True
@pytest.mark.asyncio
async def test_execute_shell_defaults_to_foreground(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
result = await ExecuteShellTool().call(
FakeWrapper(), command="chromium https://example.com"
)
assert json.loads(result)["success"] is True
assert calls == [{"command": "chromium https://example.com", "background": False}]
@pytest.mark.asyncio
async def test_execute_shell_uses_fresh_default_env_per_call(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
tool = ExecuteShellTool()
await tool.call(FakeWrapper(), command="first")
await tool.call(FakeWrapper(), command="second")
assert calls[0] is not calls[1]
assert calls[0]["MUTATED_BY_FAKE_SHELL"] == "first"
assert calls[1] == {"MUTATED_BY_FAKE_SHELL": "second"}
@pytest.mark.asyncio
async def test_execute_shell_copies_user_env_before_execution(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
original_env = {"FOO": "bar"}
await ExecuteShellTool().call(FakeWrapper(), command="first", env=original_env)
assert original_env == {"FOO": "bar"}
assert calls == [{"FOO": "bar", "MUTATED_BY_FAKE_SHELL": "first"}]
@pytest.mark.asyncio
async def test_execute_shell_avoids_double_background_for_detached_commands(
monkeypatch,
):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
command = "nohup firefox >/tmp/astrbot-firefox.log 2>&1 &"
result = await ExecuteShellTool().call(
FakeWrapper(), command=command, background=True
)
assert json.loads(result)["success"] is True
assert calls == [{"command": command, "background": False}]
@pytest.mark.asyncio
async def test_execute_shell_recognizes_commented_background_command(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
command = "firefox & # already detached"
result = await ExecuteShellTool().call(
FakeWrapper(), command=command, background=True
)
assert json.loads(result)["success"] is True
assert calls == [{"command": command, "background": False}]
@pytest.mark.parametrize(
("command", "expected"),
[
("echo '#'", False),
("echo '&'", False),
("echo foo#bar &", True),
("echo 'unterminated", False),
("firefox & # already detached", True),
("nohup firefox >/tmp/astrbot-firefox.log 2>&1 &", True),
("firefox", False),
],
)
def test_is_self_detached_command_handles_quotes_and_comments(command, expected):
from astrbot.core.tools.computer_tools.shell import _is_self_detached_command
assert _is_self_detached_command(command) is expected
@pytest.mark.asyncio
async def test_execute_shell_reports_blank_exception_type(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
class BlankError(Exception):
def __str__(self):
return ""
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
raise BlankError()
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
result = await ExecuteShellTool().call(FakeWrapper(), command="firefox")
assert result == "Error executing command: BlankError"
def test_firecrawl_tools_are_registered_as_builtin_tools():
manager = FunctionToolManager()
search_tool = manager.get_builtin_tool(FirecrawlWebSearchTool)
extract_tool = manager.get_builtin_tool(FirecrawlExtractWebPageTool)
assert search_tool.name == "web_search_firecrawl"
assert extract_tool.name == "firecrawl_extract_web_page"
assert manager.is_builtin_tool("web_search_firecrawl") is True
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True