Skip to content

Commit 288baf2

Browse files
committed
fix: harden sandbox cleanup and tool scoping
1 parent cf9221b commit 288baf2

11 files changed

Lines changed: 230 additions & 45 deletions

astrbot/core/astr_agent_tool_exec.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ def _build_handoff_toolset(
342342
# "all tools", including runtime computer-use tools.
343343
if tools is None:
344344
toolset = ToolSet()
345-
for registered_tool in llm_tools.func_list:
345+
for registered_tool in getattr(tool_mgr, "func_list", llm_tools.func_list):
346346
if isinstance(registered_tool, HandoffTool):
347347
continue
348348
if registered_tool.active and cls._tool_available_for_runtime_config(
@@ -359,7 +359,7 @@ def _build_handoff_toolset(
359359
toolset = ToolSet()
360360
for tool_name_or_obj in tools:
361361
if isinstance(tool_name_or_obj, str):
362-
registered_tool = llm_tools.get_func(tool_name_or_obj)
362+
registered_tool = tool_mgr.get_func(tool_name_or_obj)
363363
if (
364364
registered_tool
365365
and registered_tool.active

astrbot/core/astr_main_agent.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,13 @@ async def _ensure_persona_and_skills(
520520
"If you need to use these capabilities, ask the user to enable Computer Use in the AstrBot WebUI -> Config."
521521
)
522522
tmgr = plugin_context.get_llm_tool_manager()
523+
persona_tools_configured = bool(persona and persona.get("tools") is not None)
524+
req._persona_tools_configured = persona_tools_configured
525+
req._persona_allowed_tool_names = (
526+
{str(tool_name) for tool_name in persona.get("tools", [])}
527+
if persona_tools_configured
528+
else None
529+
)
523530

524531
# inject toolset in the persona
525532
if (persona and persona.get("tools") is None) or not persona:
@@ -1068,28 +1075,42 @@ def _apply_sandbox_tools(
10681075
req.func_tool = ToolSet()
10691076
if req.system_prompt is None:
10701077
req.system_prompt = ""
1078+
allowed_tool_names = getattr(req, "_persona_allowed_tool_names", None)
1079+
persona_tools_configured = bool(getattr(req, "_persona_tools_configured", False))
1080+
10711081
tool_mgr = llm_tools
1072-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExecuteShellTool))
1073-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSandboxesTool))
1074-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSandboxProvidersTool))
1075-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetCurrentSandboxTool))
1076-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSandboxTool))
1077-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(SwitchSandboxTool))
1078-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(KeepAliveSandboxTool))
1079-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ReleaseSandboxTool))
1080-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(SetSandboxRetentionPolicyTool))
1081-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(TakeoverSandboxTool))
1082-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(DestroySandboxTool))
1083-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ScreenshotSandboxTool))
1084-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CopyFileBetweenSandboxesTool))
1085-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(PythonTool))
1086-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileUploadTool))
1087-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileDownloadTool))
1088-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileReadTool))
1089-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool))
1090-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool))
1091-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool))
1092-
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"
1082+
added_tool = False
1083+
1084+
def add_sandbox_tool(tool_cls) -> None:
1085+
nonlocal added_tool
1086+
tool = tool_mgr.get_builtin_tool(tool_cls)
1087+
if persona_tools_configured and tool.name not in allowed_tool_names:
1088+
return
1089+
req.func_tool.add_tool(tool)
1090+
added_tool = True
1091+
1092+
add_sandbox_tool(ExecuteShellTool)
1093+
add_sandbox_tool(ListSandboxesTool)
1094+
add_sandbox_tool(ListSandboxProvidersTool)
1095+
add_sandbox_tool(GetCurrentSandboxTool)
1096+
add_sandbox_tool(CreateSandboxTool)
1097+
add_sandbox_tool(SwitchSandboxTool)
1098+
add_sandbox_tool(KeepAliveSandboxTool)
1099+
add_sandbox_tool(ReleaseSandboxTool)
1100+
add_sandbox_tool(SetSandboxRetentionPolicyTool)
1101+
add_sandbox_tool(TakeoverSandboxTool)
1102+
add_sandbox_tool(DestroySandboxTool)
1103+
add_sandbox_tool(ScreenshotSandboxTool)
1104+
add_sandbox_tool(CopyFileBetweenSandboxesTool)
1105+
add_sandbox_tool(PythonTool)
1106+
add_sandbox_tool(FileUploadTool)
1107+
add_sandbox_tool(FileDownloadTool)
1108+
add_sandbox_tool(FileReadTool)
1109+
add_sandbox_tool(FileWriteTool)
1110+
add_sandbox_tool(FileEditTool)
1111+
add_sandbox_tool(GrepTool)
1112+
if added_tool:
1113+
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"
10931114

10941115

10951116
def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) -> None:

astrbot/core/computer/computer_client.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,13 @@ def _pop_live_booter(sandbox_id: str):
205205
preserved += 1
206206
continue
207207
if booter is not None and provider is not None:
208-
await _safe_destroy_booter(provider, booter, record)
208+
if not await _safe_destroy_booter(provider, booter, record):
209+
sandbox_manager.session_booter[sandbox_id] = booter
210+
sandbox_manager.registry.update_sandbox_status(
211+
sandbox_id,
212+
"error",
213+
)
214+
continue
209215
sandbox_manager.registry.delete_sandbox(sandbox_id)
210216
removed += 1
211217

@@ -225,7 +231,14 @@ def _pop_live_booter(sandbox_id: str):
225231
sandbox_manager.clear_idle_state(sandbox_id)
226232
sandbox_manager.drop_boot_lock(sandbox_id)
227233
if provider is not None:
228-
await _safe_destroy_booter(provider, booter, record)
234+
if not await _safe_destroy_booter(provider, booter, record):
235+
sandbox_manager.session_booter[sandbox_id] = booter
236+
if sandbox_manager.registry.get_sandbox(sandbox_id) is not None:
237+
sandbox_manager.registry.update_sandbox_status(
238+
sandbox_id,
239+
"error",
240+
)
241+
continue
229242
if sandbox_manager.registry.get_sandbox(sandbox_id) is not None:
230243
sandbox_manager.registry.delete_sandbox(sandbox_id)
231244
removed += 1
@@ -252,15 +265,17 @@ def detach_sandbox_provider(provider_id: str) -> None:
252265

253266
async def _safe_destroy_booter(
254267
provider: SandboxProvider, booter: ComputerBooter, record: dict
255-
) -> None:
268+
) -> bool:
256269
try:
257270
await provider.destroy_booter(booter, record)
271+
return True
258272
except Exception as exc:
259273
logger.warning(
260274
"Background destroy_booter failed for sandbox %s: %s",
261275
record.get("sandbox_id"),
262276
exc,
263277
)
278+
return False
264279

265280

266281
async def _safe_shutdown_booter(booter: ComputerBooter, record: dict) -> None:

astrbot/core/computer/sandbox_manager.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,25 +1313,33 @@ async def _destroy_sandbox_cleanup(
13131313
sandbox_id: str,
13141314
record: dict,
13151315
) -> None:
1316+
destroy_err: Exception | None = None
13161317
async with self._sandbox_boot_lock(sandbox_id):
13171318
current = self.registry.get_sandbox(sandbox_id) or record
13181319
booter = self.session_booter.get(sandbox_id)
13191320
if booter is not None:
13201321
try:
13211322
await provider.destroy_booter(booter, current)
1322-
except Exception as destroy_err:
1323+
except Exception as exc:
1324+
destroy_err = exc
13231325
logger.warning(
13241326
"[Computer] destroy_booter failed for %s: %s",
13251327
sandbox_id,
1326-
destroy_err,
1328+
exc,
13271329
)
1328-
finally:
1330+
self.registry.update_sandbox_status(sandbox_id, SandboxStatus.ERROR)
1331+
await self.save_registry_async()
1332+
else:
13291333
self.clear_runtime_state(sandbox_id)
1330-
self.registry.delete_sandbox(sandbox_id)
1331-
await self.save_registry_async()
1334+
if destroy_err is None:
1335+
self.registry.delete_sandbox(sandbox_id)
1336+
await self.save_registry_async()
13321337

13331338
self.drop_boot_lock(sandbox_id)
13341339

1340+
if destroy_err is not None:
1341+
raise destroy_err
1342+
13351343
if hasattr(provider, "on_sandbox_destroyed"):
13361344
try:
13371345
await provider.on_sandbox_destroyed(record)

astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,7 @@ async def send_message(
194194
await cls._upload_file_segment(bot, seg, is_group, session_id)
195195
except Exception:
196196
messages = await cls._parse_onebot_json(MessageChain([seg]))
197-
await cls._dispatch_send(
198-
bot, event, is_group, session_id, messages
199-
)
197+
await cls._dispatch_send(bot, event, is_group, session_id, messages)
200198
else:
201199
messages = await cls._parse_onebot_json(MessageChain([seg]))
202200
if not messages:

astrbot/dashboard/routes/config.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -307,16 +307,9 @@ async def _validate_neo_connectivity(
307307
return "⚠️ Shipyard Neo endpoint 未设置"
308308

309309
access_token = sandbox.get("shipyard_neo_access_token", "")
310-
if not access_token:
311-
# Try auto-discovery
312-
from astrbot.core.computer.computer_client import _discover_bay_credentials
313-
314-
access_token = _discover_bay_credentials(endpoint)
315-
316310
if not access_token:
317311
return (
318-
"⚠️ 未找到 Bay API Key。请填写访问令牌,"
319-
"或确保 Bay 的 credentials.json 可被自动发现。"
312+
"⚠️ 未找到 Bay API Key。请填写访问令牌,或切换到新的 Sandbox Provider 配置。"
320313
)
321314

322315
# Connectivity check

tests/test_dashboard.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,7 +1081,11 @@ async def test_auth_rate_limit_uses_same_bucket_across_paths(
10811081
cfg = core_lifecycle_td.astrbot_config["dashboard"]
10821082
rl_original = cfg.get("auth_rate_limit", {})
10831083
tp_original = cfg.get("trust_proxy_headers", False)
1084-
cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1}
1084+
cfg["auth_rate_limit"] = {
1085+
"enable": True,
1086+
"average_interval": 3600.0,
1087+
"max_burst": 1,
1088+
}
10851089
cfg["trust_proxy_headers"] = True
10861090

10871091
try:
@@ -1113,7 +1117,11 @@ async def test_auth_rate_limit_separates_different_client_ips(
11131117
cfg = core_lifecycle_td.astrbot_config["dashboard"]
11141118
rl_original = cfg.get("auth_rate_limit", {})
11151119
tp_original = cfg.get("trust_proxy_headers", False)
1116-
cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1}
1120+
cfg["auth_rate_limit"] = {
1121+
"enable": True,
1122+
"average_interval": 3600.0,
1123+
"max_burst": 1,
1124+
}
11171125
cfg["trust_proxy_headers"] = True
11181126

11191127
try:
@@ -1157,7 +1165,11 @@ async def test_auth_rate_limit_ignores_proxy_headers_by_default(
11571165
cfg = core_lifecycle_td.astrbot_config["dashboard"]
11581166
rl_original = cfg.get("auth_rate_limit", {})
11591167
tp_original = cfg.get("trust_proxy_headers", False)
1160-
cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1}
1168+
cfg["auth_rate_limit"] = {
1169+
"enable": True,
1170+
"average_interval": 3600.0,
1171+
"max_burst": 1,
1172+
}
11611173
cfg["trust_proxy_headers"] = False
11621174

11631175
try:
@@ -1570,6 +1582,27 @@ async def test_config_save_rejects_recovery_code_for_protected_totp_changes(
15701582
)
15711583

15721584

1585+
@pytest.mark.asyncio
1586+
async def test_validate_neo_connectivity_without_token_returns_warning_not_import_error():
1587+
from astrbot.dashboard.routes.config import _validate_neo_connectivity
1588+
1589+
warning = await _validate_neo_connectivity(
1590+
{
1591+
"provider_settings": {
1592+
"computer_use_runtime": "sandbox",
1593+
"sandbox": {
1594+
"booter": "shipyard_neo",
1595+
"shipyard_neo_endpoint": "http://127.0.0.1:65535",
1596+
"shipyard_neo_access_token": "",
1597+
},
1598+
}
1599+
}
1600+
)
1601+
1602+
assert warning is not None
1603+
assert "API Key" in warning
1604+
1605+
15731606
@pytest.mark.asyncio
15741607
async def test_auth_totp_setup_with_valid_code_returns_recovery_code(
15751608
app: Quart,

tests/unit/test_astr_agent_tool_exec.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,44 @@ async def test_build_handoff_toolset_uses_registered_provider_tools_only(
296296
FunctionToolExecutor._runtime_computer_tools_cache.clear()
297297

298298

299+
@pytest.mark.asyncio
300+
async def test_build_handoff_toolset_uses_scoped_tool_manager_for_all_tools():
301+
from astrbot.core.agent.tool import FunctionTool
302+
303+
allowed_tool = FunctionTool(
304+
name="allowed_tool",
305+
parameters={"type": "object", "properties": {}},
306+
description="allowed",
307+
)
308+
disallowed_tool = FunctionTool(
309+
name="disallowed_tool",
310+
parameters={"type": "object", "properties": {}},
311+
description="disallowed",
312+
)
313+
previous_tools = list(llm_tools.func_list)
314+
FunctionToolExecutor._runtime_computer_tools_cache.clear()
315+
llm_tools.func_list = [allowed_tool, disallowed_tool]
316+
tool_mgr = SimpleNamespace(func_list=[allowed_tool], get_func=lambda name: None)
317+
context = SimpleNamespace(
318+
get_config=lambda **_kwargs: {
319+
"provider_settings": {"computer_use_runtime": "none"}
320+
},
321+
get_llm_tool_manager=lambda: tool_mgr,
322+
)
323+
run_context = ContextWrapper(
324+
context=SimpleNamespace(event=_DummyEvent([]), context=context)
325+
)
326+
327+
try:
328+
toolset = FunctionToolExecutor._build_handoff_toolset(run_context, None)
329+
assert toolset is not None
330+
assert "allowed_tool" in toolset.names()
331+
assert "disallowed_tool" not in toolset.names()
332+
finally:
333+
llm_tools.func_list = previous_tools
334+
FunctionToolExecutor._runtime_computer_tools_cache.clear()
335+
336+
299337
@pytest.mark.asyncio
300338
async def test_background_wake_preserves_computer_runtime_config(
301339
monkeypatch: pytest.MonkeyPatch,

tests/unit/test_astr_main_agent.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,22 @@ async def test_ensure_tools_from_persona(self, mock_event, mock_context):
742742

743743
assert req.func_tool is not None
744744

745+
def test_apply_sandbox_tools_respects_explicit_empty_persona_tool_list(self):
746+
module = ama
747+
req = ProviderRequest(func_tool=ToolSet())
748+
req._persona_tools_configured = True
749+
req._persona_allowed_tool_names = set()
750+
config = module.MainAgentBuildConfig(
751+
tool_call_timeout=60,
752+
computer_use_runtime="sandbox",
753+
provider_settings={"computer_use_runtime": "sandbox"},
754+
)
755+
756+
module._apply_sandbox_tools(config, req)
757+
758+
assert req.func_tool is not None
759+
assert req.func_tool.empty()
760+
745761
@pytest.mark.asyncio
746762
async def test_ensure_persona_keeps_all_sandbox_provider_tools_in_sandbox_runtime(
747763
self, mock_event, mock_context

tests/unit/test_sandbox_computer_client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,49 @@ async def fake_destroy_booter(booter, record):
614614
assert persistent_booter.shutdown_calls == 1
615615

616616

617+
@pytest.mark.asyncio
618+
async def test_cleanup_sandbox_provider_preserves_temporary_record_when_destroy_fails(
619+
monkeypatch, tmp_path
620+
):
621+
from astrbot.core.computer import computer_client
622+
from astrbot.core.computer.sandbox_manager import SandboxManager
623+
from astrbot.core.computer.sandbox_registry import SandboxRegistry
624+
625+
provider = FakeProvider()
626+
627+
async def fake_destroy_booter(booter, record):
628+
raise RuntimeError("destroy failed")
629+
630+
provider.destroy_booter = fake_destroy_booter
631+
manager = SandboxManager(
632+
registry=SandboxRegistry(tmp_path / "sandbox_registry.json"),
633+
providers={provider.provider_id: provider},
634+
)
635+
monkeypatch.setattr(computer_client, "sandbox_manager", manager)
636+
manager.registry.upsert_sandbox(
637+
sandbox_id="generic-temp",
638+
sandbox_name="Temp",
639+
provider="generic",
640+
managed=True,
641+
created_by_astrbot=True,
642+
owner_user_id="session-a",
643+
owner_session_id="session-a",
644+
connect_info={},
645+
retention_policy="temporary",
646+
status="running",
647+
)
648+
booter = FakeBooter()
649+
booter.provider_id = provider.provider_id
650+
manager.session_booter["generic-temp"] = booter
651+
652+
await computer_client.cleanup_sandbox_provider("generic")
653+
654+
record = manager.registry.get_sandbox("generic-temp")
655+
assert record is not None
656+
assert record["status"] == "error"
657+
assert manager.session_booter["generic-temp"] is booter
658+
659+
617660
@pytest.mark.asyncio
618661
async def test_cleanup_sandbox_provider_cleans_live_booter_without_registry_record(
619662
monkeypatch, tmp_path

0 commit comments

Comments
 (0)