Skip to content

Commit b9d1836

Browse files
committed
fix(cua): harden sandbox lifecycle controls
1 parent 7b7b112 commit b9d1836

8 files changed

Lines changed: 307 additions & 8 deletions

File tree

astrbot/core/computer/computer_client.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
2626
cua_registry = CuaSandboxRegistry()
2727
CUA_LEASE_SECONDS = 300
28+
_cua_boot_locks: dict[str, asyncio.Lock] = {}
2829

2930

3031
@dataclass(slots=True)
@@ -59,6 +60,14 @@ def _clear_cua_idle_state(sandbox_id: str) -> None:
5960
state.task.cancel()
6061

6162

63+
def _cua_boot_lock(sandbox_id: str) -> asyncio.Lock:
64+
lock = _cua_boot_locks.get(sandbox_id)
65+
if lock is None:
66+
lock = asyncio.Lock()
67+
_cua_boot_locks[sandbox_id] = lock
68+
return lock
69+
70+
6271
def _schedule_cua_idle_cleanup(sandbox_id: str, timeout: float) -> None:
6372
_clear_cua_idle_state(sandbox_id)
6473
if timeout <= 0:
@@ -112,6 +121,11 @@ async def _expire_when_idle() -> None:
112121
)
113122
finally:
114123
session_booter.pop(sandbox_id, None)
124+
if record.get("retention_policy") == "persistent":
125+
cua_registry.update_sandbox_status(sandbox_id, "stopped")
126+
else:
127+
cua_registry.delete_sandbox(sandbox_id)
128+
_save_cua_registry()
115129
return
116130
except asyncio.CancelledError:
117131
raise
@@ -316,12 +330,13 @@ async def _get_or_create_cua_booter(
316330
)
317331
_save_cua_registry()
318332

319-
if target_sandbox_id not in session_booter:
320-
client = await _boot_managed_cua_sandbox(
321-
context, session_id, target_sandbox_id, cua_kwargs
322-
)
323-
setattr(client, "sandbox_id", target_sandbox_id)
324-
session_booter[target_sandbox_id] = client
333+
async with _cua_boot_lock(target_sandbox_id):
334+
if target_sandbox_id not in session_booter:
335+
client = await _boot_managed_cua_sandbox(
336+
context, session_id, target_sandbox_id, cua_kwargs
337+
)
338+
setattr(client, "sandbox_id", target_sandbox_id)
339+
session_booter[target_sandbox_id] = client
325340

326341
_acquire_cua_sandbox_lease(target_sandbox_id, session_id)
327342
cua_registry.touch_sandbox(target_sandbox_id)
@@ -472,6 +487,9 @@ def switch_current_cua_sandbox(session_id: str, sandbox_id: str) -> dict:
472487
record = cua_registry.get_sandbox(sandbox_id)
473488
if record is None or not record.get("managed"):
474489
raise RuntimeError(f"Sandbox {sandbox_id} not found")
490+
booter = session_booter.get(sandbox_id)
491+
if booter is None:
492+
raise RuntimeError(f"Sandbox {sandbox_id} is not running")
475493
if not _acquire_cua_sandbox_lease(sandbox_id, session_id):
476494
raise RuntimeError(f"Sandbox {sandbox_id} is busy")
477495
cua_registry.set_current_sandbox_id(session_id, sandbox_id)

astrbot/core/computer/cua_registry.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ def update_sandbox_config(
154154
record["retention_policy"] = retention_policy
155155
return deepcopy(record)
156156

157+
def update_sandbox_status(
158+
self, sandbox_id: str, status: str
159+
) -> dict[str, Any] | None:
160+
record = self._payload["sandboxes"].get(sandbox_id)
161+
if record is None:
162+
return None
163+
record["status"] = status
164+
return deepcopy(record)
165+
157166
def acquire_lease(
158167
self,
159168
*,

astrbot/core/core_lifecycle.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ async def stop(self) -> None:
400400

401401
async def restart(self) -> None:
402402
"""重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例"""
403+
await cleanup_managed_cua_sandboxes()
403404
await self.provider_manager.terminate()
404405
await self.platform_manager.terminate()
405406
await self.kb_manager.terminate()

astrbot/core/tools/computer_tools/cua_sandbox.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ async def call(
226226
context: ContextWrapper[AstrAgentContext],
227227
sandbox_id: str,
228228
) -> ToolExecResult:
229+
if err := check_admin_permission(context, "Taking over CUA sandboxes"):
230+
return err
229231
try:
230232
sandbox = takeover_cua_sandbox(_session_id(context), sandbox_id)
231233
return _to_json({"success": True, "sandbox": sandbox})
@@ -294,6 +296,8 @@ async def call(
294296
send_to_user: bool = True,
295297
return_image_to_llm: bool = True,
296298
) -> ToolExecResult:
299+
if err := check_admin_permission(context, "Taking CUA sandbox screenshots"):
300+
return err
297301
try:
298302
booter = await get_cua_sandbox_observer_booter_by_id(sandbox_id)
299303
gui = getattr(booter, "gui", None)

dashboard/src/views/SandboxManagementPage.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
{{ tm('actions.console') }}
8686
<v-tooltip activator="parent" location="top">{{ tm('tooltips.console') }}</v-tooltip>
8787
</v-btn>
88-
<v-btn size="small" variant="tonal" :disabled="!isCua(item) || !hasController(item)" @click="releaseSandbox(item)">{{ tm('actions.release') }}</v-btn>
88+
<v-btn size="small" variant="tonal" :disabled="!canReleaseFromDashboard(item)" @click="releaseSandbox(item)">{{ tm('actions.release') }}</v-btn>
8989
<v-btn size="small" variant="tonal" :disabled="!isCua(item)" @click="screenshotSandbox(item)">{{ tm('actions.screenshot') }}</v-btn>
9090
<v-btn size="small" color="error" variant="tonal" :disabled="!isCua(item)" @click="openDestroyConfirm(item)">{{ tm('actions.destroy') }}</v-btn>
9191
</div>
@@ -375,6 +375,10 @@ function hasController(item: SandboxRecord) {
375375
return !!item.controller_session_id
376376
}
377377
378+
function canReleaseFromDashboard(item: SandboxRecord) {
379+
return isCua(item) && item.controller_session_id === 'dashboard'
380+
}
381+
378382
function formatTime(value?: number | null) {
379383
if (!value) return '-'
380384
return new Date(value * 1000).toLocaleString()
@@ -517,6 +521,7 @@ async function confirmDestroySandbox() {
517521
518522
async function screenshotSandbox(item: SandboxRecord) {
519523
const data = await postAction('/api/sandboxes/screenshot', { sandbox_id: item.sandbox_id })
524+
if (!data) return
520525
const screenshot = data?.screenshot
521526
const legacyResult = data?.result
522527
const mimeType = screenshot?.mime_type || legacyResult?.mime_type || 'image/png'
@@ -567,7 +572,7 @@ function quoteForShell(value: string) {
567572
568573
function buildConsoleShellCommand(command: string, cwd: string) {
569574
const prefix = cwd && cwd !== '~' ? `cd ${quoteForShell(cwd)} && ` : ''
570-
return `${prefix}${command}; printf '\n__ASTRBOT_CWD__%s\n' "$PWD"`
575+
return `${prefix}{ ${command}; __astrbot_status=$?; }; printf '\n__ASTRBOT_CWD__%s\n' "$PWD"; exit $__astrbot_status`
571576
}
572577
573578
function parseConsoleShellResult(stdout: string, fallbackCwd: string) {

tests/unit/test_core_lifecycle.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,31 @@ async def test_restart_terminates_managers_and_starts_thread(
942942
mock_thread.assert_called_once()
943943
mock_thread.return_value.start.assert_called_once()
944944

945+
@pytest.mark.asyncio
946+
async def test_restart_cleans_up_managed_cua_sandboxes(
947+
self, mock_log_broker, mock_db
948+
):
949+
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
950+
lifecycle.provider_manager = MagicMock()
951+
lifecycle.provider_manager.terminate = AsyncMock()
952+
lifecycle.platform_manager = MagicMock()
953+
lifecycle.platform_manager.terminate = AsyncMock()
954+
lifecycle.kb_manager = MagicMock()
955+
lifecycle.kb_manager.terminate = AsyncMock()
956+
lifecycle.dashboard_shutdown_event = asyncio.Event()
957+
lifecycle.astrbot_updator = MagicMock()
958+
959+
with (
960+
patch("astrbot.core.core_lifecycle.threading.Thread"),
961+
patch(
962+
"astrbot.core.core_lifecycle.cleanup_managed_cua_sandboxes",
963+
new_callable=AsyncMock,
964+
) as cleanup,
965+
):
966+
await lifecycle.restart()
967+
968+
cleanup.assert_awaited_once()
969+
945970

946971
class TestAstrBotCoreLifecycleLoadPipelineScheduler:
947972
"""Tests for AstrBotCoreLifecycle.load_pipeline_scheduler method."""

tests/unit/test_cua_computer_use.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,92 @@ async def available(self):
546546
await computer_client.get_booter(ctx, "session-b")
547547

548548

549+
@pytest.mark.asyncio
550+
async def test_get_booter_uses_selected_running_sandbox_instead_of_default(
551+
monkeypatch,
552+
tmp_path,
553+
):
554+
from astrbot.core.computer import computer_client
555+
from astrbot.core.computer.cua_registry import CuaSandboxRegistry
556+
557+
class FakeBooter:
558+
def __init__(self, sandbox_id: str):
559+
self.sandbox_id = sandbox_id
560+
561+
async def available(self):
562+
return True
563+
564+
registry = CuaSandboxRegistry(storage_path=tmp_path / "registry.json")
565+
registry.upsert_sandbox(
566+
sandbox_id="sb-default",
567+
sandbox_name="default",
568+
booter_type="cua",
569+
provider="cua",
570+
managed=True,
571+
created_by_astrbot=True,
572+
owner_user_id="session-a",
573+
owner_session_id="session-a",
574+
connect_info={},
575+
is_default=True,
576+
)
577+
registry.upsert_sandbox(
578+
sandbox_id="sb-selected",
579+
sandbox_name="selected",
580+
booter_type="cua",
581+
provider="cua",
582+
managed=True,
583+
created_by_astrbot=True,
584+
owner_user_id="session-a",
585+
owner_session_id="session-a",
586+
connect_info={},
587+
)
588+
registry.set_current_sandbox_id("session-a", "sb-selected")
589+
monkeypatch.setattr(computer_client, "cua_registry", registry)
590+
computer_client.session_booter.clear()
591+
computer_client.session_booter["sb-default"] = FakeBooter("sb-default")
592+
computer_client.session_booter["sb-selected"] = FakeBooter("sb-selected")
593+
594+
booter = await computer_client.get_booter(
595+
FakeContext(
596+
{
597+
"provider_settings": {
598+
"computer_use_runtime": "sandbox",
599+
"sandbox": {"booter": "cua"},
600+
}
601+
}
602+
),
603+
"session-a",
604+
)
605+
606+
assert booter.sandbox_id == "sb-selected"
607+
608+
609+
@pytest.mark.asyncio
610+
async def test_switch_current_rejects_non_running_sandbox(monkeypatch, tmp_path):
611+
from astrbot.core.computer import computer_client
612+
from astrbot.core.computer.cua_registry import CuaSandboxRegistry
613+
614+
registry = CuaSandboxRegistry(storage_path=tmp_path / "registry.json")
615+
registry.upsert_sandbox(
616+
sandbox_id="sb-stale",
617+
sandbox_name="stale",
618+
booter_type="cua",
619+
provider="cua",
620+
managed=True,
621+
created_by_astrbot=True,
622+
owner_user_id="session-a",
623+
owner_session_id="session-a",
624+
connect_info={},
625+
)
626+
monkeypatch.setattr(computer_client, "cua_registry", registry)
627+
computer_client.session_booter.clear()
628+
629+
with pytest.raises(RuntimeError, match="not running"):
630+
computer_client.switch_current_cua_sandbox("session-a", "sb-stale")
631+
632+
assert registry.get_current_sandbox_id("session-a") is None
633+
634+
549635
@pytest.mark.asyncio
550636
async def test_busy_sandbox_screenshot_is_allowed_without_taking_control(
551637
monkeypatch, tmp_path
@@ -935,6 +1021,53 @@ async def available(self):
9351021
)
9361022

9371023

1024+
@pytest.mark.asyncio
1025+
async def test_get_booter_serializes_concurrent_default_boot(monkeypatch, tmp_path):
1026+
from astrbot.core.computer import computer_client
1027+
from astrbot.core.computer.cua_registry import CuaSandboxRegistry
1028+
1029+
boot_started = asyncio.Event()
1030+
release_boot = asyncio.Event()
1031+
boots = []
1032+
1033+
class FakeBooter:
1034+
def __init__(self, sandbox_id: str):
1035+
self.sandbox_id = sandbox_id
1036+
1037+
async def available(self):
1038+
return True
1039+
1040+
async def fake_boot_managed(ctx, session_id, sandbox_id, cua_kwargs):
1041+
boots.append(sandbox_id)
1042+
boot_started.set()
1043+
await release_boot.wait()
1044+
return FakeBooter(sandbox_id)
1045+
1046+
registry = CuaSandboxRegistry(storage_path=tmp_path / "registry.json")
1047+
monkeypatch.setattr(computer_client, "cua_registry", registry)
1048+
monkeypatch.setattr(computer_client, "_boot_managed_cua_sandbox", fake_boot_managed)
1049+
computer_client.session_booter.clear()
1050+
1051+
ctx = FakeContext(
1052+
{
1053+
"provider_settings": {
1054+
"computer_use_runtime": "sandbox",
1055+
"sandbox": {"booter": "cua"},
1056+
}
1057+
}
1058+
)
1059+
task_one = asyncio.create_task(computer_client.get_booter(ctx, "session-a"))
1060+
await boot_started.wait()
1061+
task_two = asyncio.create_task(computer_client.get_booter(ctx, "session-a"))
1062+
await asyncio.sleep(0)
1063+
release_boot.set()
1064+
1065+
booter_one, booter_two = await asyncio.gather(task_one, task_two)
1066+
1067+
assert booter_one is booter_two
1068+
assert len(boots) == 1
1069+
1070+
9381071
@pytest.mark.asyncio
9391072
async def test_get_booter_shuts_down_client_when_skill_sync_fails(monkeypatch):
9401073
from astrbot.core.computer import computer_client
@@ -1036,6 +1169,48 @@ async def shutdown(self):
10361169

10371170
assert shutdowns == [booter.session_id]
10381171
assert booter.sandbox_id not in computer_client.session_booter
1172+
assert registry.get_sandbox(booter.sandbox_id) is None
1173+
1174+
1175+
@pytest.mark.asyncio
1176+
async def test_cua_idle_timeout_marks_persistent_sandbox_stopped_after_lease_release(
1177+
monkeypatch,
1178+
):
1179+
from astrbot.core.computer import computer_client
1180+
from astrbot.core.computer.cua_registry import CuaSandboxRegistry
1181+
1182+
shutdowns = []
1183+
1184+
class FakeBooter:
1185+
async def shutdown(self):
1186+
shutdowns.append("sb-persistent")
1187+
1188+
registry = CuaSandboxRegistry(storage_path=Path("/tmp/cua-idle-persistent.json"))
1189+
registry.upsert_sandbox(
1190+
sandbox_id="sb-persistent",
1191+
sandbox_name="persistent",
1192+
booter_type="cua",
1193+
provider="cua",
1194+
managed=True,
1195+
created_by_astrbot=True,
1196+
owner_user_id="session-a",
1197+
owner_session_id="session-a",
1198+
connect_info={},
1199+
retention_policy="persistent",
1200+
idle_timeout=0.05,
1201+
)
1202+
monkeypatch.setattr(computer_client, "cua_registry", registry)
1203+
computer_client.session_booter.clear()
1204+
computer_client.session_booter["sb-persistent"] = FakeBooter()
1205+
1206+
computer_client._schedule_cua_idle_cleanup("sb-persistent", 0.05)
1207+
await asyncio.sleep(0.1)
1208+
1209+
record = registry.get_sandbox("sb-persistent")
1210+
assert shutdowns == ["sb-persistent"]
1211+
assert record is not None
1212+
assert record["status"] == "stopped"
1213+
assert "sb-persistent" not in computer_client.session_booter
10391214

10401215

10411216
@pytest.mark.asyncio

0 commit comments

Comments
 (0)