From 33d99ba20be46636ba4670f12f0a1896337101a0 Mon Sep 17 00:00:00 2001 From: RuochenPan Date: Mon, 7 Sep 2026 15:09:57 +0800 Subject: [PATCH] feat: expose runtime and sandbox dependency information --- astrbot/dashboard/services/stat_service.py | 28 ++++++++ .../src/api/generated/openapi-v1/sdk.gen.ts | 3 +- .../src/api/generated/openapi-v1/types.gen.ts | 37 +++++++++- dashboard/src/api/v1.ts | 2 + openspec/openapi-v1.yaml | 42 +++++++++++- tests/test_fastapi_v1_dashboard.py | 67 ++++++++++++++++++- tests/unit/test_stat_service.py | 44 +++++++++++- 7 files changed, 217 insertions(+), 6 deletions(-) diff --git a/astrbot/dashboard/services/stat_service.py b/astrbot/dashboard/services/stat_service.py index 6ebc3bfba..e4f8b09ec 100644 --- a/astrbot/dashboard/services/stat_service.py +++ b/astrbot/dashboard/services/stat_service.py @@ -2,7 +2,9 @@ from __future__ import annotations import ast import asyncio +import platform import re +import shutil import threading import time import traceback @@ -60,6 +62,30 @@ class StatService: self.config = config self.storage_cleaner = StorageCleaner(config) + # Snapshot dependency presence at startup; this does not test sandbox launch. + system = platform.system().lower() + sandbox = {"backend": None, "status": "unsupported"} + if system == "linux": + sandbox = { + "backend": "bubblewrap", + "status": "detected" if shutil.which("bwrap") else "missing", + } + elif system == "darwin": + sandbox = { + "backend": "seatbelt", + "status": ( + "detected" + if shutil.which("sandbox-exec", path="/usr/bin") + == "/usr/bin/sandbox-exec" + else "missing" + ), + } + self.runtime = { + "os": system, + "arch": platform.machine(), + "sandbox": sandbox, + } + async def restart_core(self) -> None: if DEMO_MODE: raise StatServiceError( @@ -107,6 +133,7 @@ class StatService: "change_pwd_hint": False, "md5_pwd_hint": False, "password_upgrade_required": False, + "runtime": self.runtime, } storage_upgraded = await is_password_storage_upgraded( self.db_helper, @@ -124,6 +151,7 @@ class StatService: "change_pwd_hint": await self.is_default_cred(), "md5_pwd_hint": md5_pwd_hint, "password_upgrade_required": not storage_upgraded, + "runtime": self.runtime, } async def get_public_versions( diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 0d4603186..973b1971b 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -2647,7 +2647,8 @@ export const getProviderTokenStats = (opti }; /** - * Get AstrBot version + * Get AstrBot version and runtime information + * Runtime information is detected once at application startup. Restart AstrBot after installing sandbox dependencies to refresh it. */ export const getVersion = (options?: OptionsLegacyParser) => { return (options?.client ?? client).get({ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 48b9f6bfe..c09932ebf 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -592,6 +592,37 @@ export type ReorderRequest = { }>; }; +/** + * The AstrBot backend runtime, including when running inside a container. Values are captured at application startup. + */ +export type RuntimeInfo = { + /** + * Lowercase platform.system() value, commonly linux, darwin, or windows. + */ + os: string; + /** + * Unmodified platform.machine() value, such as x86_64, AMD64, arm64, or aarch64. May be empty if unknown. + */ + arch: string; + /** + * Local process sandbox dependency detection. It does not describe file tool permissions or verify that sandboxed processes can launch. + */ + sandbox: { + backend: ('bubblewrap' | 'seatbelt') | null; + /** + * detected means bwrap was found in PATH or /usr/bin/sandbox-exec was found on macOS; missing means the corresponding executable was not found; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language. + */ + status: 'detected' | 'missing' | 'unsupported'; + }; +}; + +export type backend = 'bubblewrap' | 'seatbelt'; + +/** + * detected means bwrap was found in PATH or /usr/bin/sandbox-exec was found on macOS; missing means the corresponding executable was not found; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language. + */ +export type status = 'detected' | 'missing' | 'unsupported'; + export type SessionGroupRequest = { name?: string; umos?: Array<(string)>; @@ -3282,7 +3313,11 @@ export type GetProviderTokenStatsResponse = (SuccessEnvelope); export type GetProviderTokenStatsError = unknown; -export type GetVersionResponse = (SuccessEnvelope); +export type GetVersionResponse = ((SuccessEnvelope & { + data?: { + runtime: RuntimeInfo; + }; +})); export type GetVersionError = unknown; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 709d4060a..f43da36f6 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -43,6 +43,7 @@ import { type PluginValidateRepoRequest, type PluginConfigFileDeleteRequest, type ProviderConfigRequest, + type RuntimeInfo, type BatchSessionProviderRequest, type BatchSessionServiceRequest, type SetupAuthRequest, @@ -120,6 +121,7 @@ export interface VersionData { change_pwd_hint?: boolean; md5_pwd_hint?: boolean; password_upgrade_required?: boolean; + runtime?: RuntimeInfo; [key: string]: unknown; } diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index 35f50287b..6a16f90f6 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -4389,12 +4389,26 @@ paths: /api/v1/stats/version: get: tags: [Stats] - summary: Get AstrBot version + summary: Get AstrBot version and runtime information + description: Runtime information is detected once at application startup. Restart AstrBot after installing sandbox dependencies to refresh it. operationId: getVersion x-astrbot-scope: system responses: "200": - $ref: "#/components/responses/Ok" + description: Version information with a backend runtime snapshot + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessEnvelope" + - type: object + properties: + data: + type: object + required: [runtime] + properties: + runtime: + $ref: "#/components/schemas/RuntimeInfo" /api/v1/stats/versions: get: @@ -5296,6 +5310,30 @@ components: type: string schemas: + RuntimeInfo: + type: object + description: The AstrBot backend runtime, including when running inside a container. Values are captured at application startup. + required: [os, arch, sandbox] + properties: + os: + type: string + description: Lowercase platform.system() value, commonly linux, darwin, or windows. + arch: + type: string + description: Unmodified platform.machine() value, such as x86_64, AMD64, arm64, or aarch64. May be empty if unknown. + sandbox: + type: object + description: Local process sandbox dependency detection. It does not describe file tool permissions or verify that sandboxed processes can launch. + required: [backend, status] + properties: + backend: + type: [string, "null"] + enum: [bubblewrap, seatbelt, null] + status: + type: string + enum: [detected, missing, unsupported] + description: detected means bwrap was found in PATH or /usr/bin/sandbox-exec was found on macOS; missing means the corresponding executable was not found; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language. + SuccessEnvelope: type: object required: [status, data] diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index fb0b9087a..64a56cf4e 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -3,7 +3,7 @@ import json from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import httpx import jwt @@ -13,6 +13,7 @@ from fastapi import FastAPI, Request from fastapi.responses import FileResponse, PlainTextResponse import astrbot.dashboard.services.config_service as config_service +import astrbot.dashboard.services.stat_service as stat_service from astrbot.core import file_token_service from astrbot.core.utils import llm_metadata from astrbot.dashboard.api.app import create_dashboard_asgi_app @@ -1042,6 +1043,70 @@ def _jwt_headers() -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +@pytest.mark.asyncio +@pytest.mark.parametrize("desktop_auth", [False, True]) +async def test_version_routes_return_startup_runtime_snapshot( + monkeypatch, fake_core_lifecycle, fake_db: FakeDb, desktop_auth +): + """Both version routes and auth modes expose the same startup snapshot.""" + platform = SimpleNamespace( + system=Mock(return_value="Linux"), machine=Mock(return_value="aarch64") + ) + which = Mock(return_value=None) + monkeypatch.setattr(stat_service, "platform", platform) + monkeypatch.setattr(stat_service, "shutil", SimpleNamespace(which=which)) + monkeypatch.setattr( + stat_service, "is_desktop_session_auth_enabled", lambda: desktop_auth + ) + monkeypatch.setattr( + stat_service, "get_dashboard_version", AsyncMock(return_value="v1.2.3") + ) + monkeypatch.setattr( + stat_service, "is_password_storage_upgraded", AsyncMock(return_value=True) + ) + monkeypatch.setattr( + stat_service, + "get_dashboard_password_hash", + lambda *args, **kwargs: "stored-hash", + ) + monkeypatch.setattr( + stat_service.StatService, "is_default_cred", AsyncMock(return_value=False) + ) + app = create_dashboard_asgi_app( + core_lifecycle=fake_core_lifecycle, db=fake_db, jwt_secret=JWT_SECRET + ) + + # Installing a dependency after startup takes effect after restarting AstrBot. + which.return_value = "/usr/bin/bwrap" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + for path in ("/api/v1/stats/version", "/api/stat/version"): + response = await client.get(path, headers=_jwt_headers()) + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert payload["data"]["runtime"] == { + "os": "linux", + "arch": "aarch64", + "sandbox": {"backend": "bubblewrap", "status": "missing"}, + } + assert payload["data"]["version"] + assert payload["data"]["dashboard_version"] == "v1.2.3" + assert payload["data"]["change_pwd_hint"] is False + assert payload["data"]["md5_pwd_hint"] is False + assert payload["data"]["password_upgrade_required"] is False + + response = await client.get("/api/v1/stats/versions") + assert response.status_code == 200 + assert "runtime" not in response.json()["data"] + + which.assert_called_once_with("bwrap") + platform.system.assert_called_once_with() + platform.machine.assert_called_once_with() + + @pytest.mark.asyncio async def test_public_versions_route_uses_static_folder( fake_core_lifecycle, diff --git a/tests/unit/test_stat_service.py b/tests/unit/test_stat_service.py index 9f6b1ab1a..a58b4df8b 100644 --- a/tests/unit/test_stat_service.py +++ b/tests/unit/test_stat_service.py @@ -1,9 +1,11 @@ import time from datetime import datetime, timedelta -from unittest.mock import MagicMock +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock import pytest +import astrbot.dashboard.services.stat_service as stat_service from astrbot.dashboard.services.stat_service import StatService @@ -16,6 +18,46 @@ def _make_service(db) -> StatService: return StatService(db_helper=db, core_lifecycle=core_lifecycle, config={}) +@pytest.mark.parametrize( + ("system", "arch", "executable", "backend", "status"), + [ + ("Linux", "x86_64", "/usr/bin/bwrap", "bubblewrap", "detected"), + ("Linux", "aarch64", None, "bubblewrap", "missing"), + ("Darwin", "arm64", "/usr/bin/sandbox-exec", "seatbelt", "detected"), + ("Darwin", "x86_64", None, "seatbelt", "missing"), + ("Darwin", "arm64", "/opt/bin/sandbox-exec", "seatbelt", "missing"), + ("Windows", "AMD64", None, None, "unsupported"), + ("Windows", "ARM64", None, None, "unsupported"), + ("FreeBSD", "", None, None, "unsupported"), + ], +) +def test_runtime_detects_platform_dependencies( + monkeypatch, system, arch, executable, backend, status +): + """Report dependency presence without requiring a native sandbox in CI.""" + monkeypatch.setattr( + stat_service, + "platform", + SimpleNamespace(system=lambda: system, machine=lambda: arch), + ) + which = Mock(return_value=executable) + monkeypatch.setattr(stat_service, "shutil", SimpleNamespace(which=which)) + + service = _make_service(MagicMock()) + + assert service.runtime == { + "os": system.lower(), + "arch": arch, + "sandbox": {"backend": backend, "status": status}, + } + if system == "Linux": + which.assert_called_once_with("bwrap") + elif system == "Darwin": + which.assert_called_once_with("sandbox-exec", path="/usr/bin") + else: + which.assert_not_called() + + @pytest.mark.asyncio async def test_get_stat_aggregates_platform_stats(temp_db): """Seeded rows must aggregate into windowed platform sums and a global total."""