feat: expose runtime and sandbox dependency information

This commit is contained in:
RuochenPan
2026-09-07 15:09:57 +08:00
parent bdc8ad1534
commit 33d99ba20b
7 changed files with 217 additions and 6 deletions
@@ -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(
@@ -2647,7 +2647,8 @@ export const getProviderTokenStats = <ThrowOnError extends boolean = false>(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 = <ThrowOnError extends boolean = false>(options?: OptionsLegacyParser<unknown, ThrowOnError>) => {
return (options?.client ?? client).get<GetVersionResponse, GetVersionError, ThrowOnError>({
@@ -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;
+2
View File
@@ -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;
}
+40 -2
View File
@@ -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]
+66 -1
View File
@@ -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,
+43 -1
View File
@@ -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."""