feat(skills): mark sandbox preset skills readonly

expose skill source metadata and sandbox cache status in the skills API
response so the dashboard can distinguish local, sandbox-only, and
synced skills.

prevent enabling, disabling, or deleting sandbox-only preset skills in
both backend guards and UI actions to avoid invalid local operations.

add source badges, discovery-pending hinting for sandbox runtime, and
new i18n strings for source labels and readonly warnings.
This commit is contained in:
RC-CHN
2026-02-27 15:22:07 +08:00
parent 13c8fa3f92
commit c1de265baf
6 changed files with 286 additions and 33 deletions
+71 -19
View File
@@ -32,6 +32,10 @@ class SkillInfo:
description: str
path: str
active: bool
source_type: str = "local_only"
source_label: str = "local"
local_exists: bool = True
sandbox_exists: bool = False
def _parse_frontmatter_description(text: str) -> str:
@@ -164,6 +168,7 @@ class SkillManager:
return {
"version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)),
"skills": skills,
"updated_at": data.get("updated_at"),
}
except Exception:
return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []}
@@ -198,6 +203,17 @@ class SkillManager:
}
self._save_sandbox_skills_cache(cache)
def get_sandbox_skills_cache_status(self) -> dict[str, object]:
cache = self._load_sandbox_skills_cache()
skills = cache.get("skills", [])
count = len(skills) if isinstance(skills, list) else 0
return {
"exists": os.path.exists(self.sandbox_skills_cache_path),
"ready": count > 0,
"count": count,
"updated_at": cache.get("updated_at"),
}
def list_skills(
self,
*,
@@ -217,15 +233,18 @@ class SkillManager:
skills_by_name: dict[str, SkillInfo] = {}
sandbox_cached_paths: dict[str, str] = {}
if runtime == "sandbox":
cache_for_paths = self._load_sandbox_skills_cache()
for item in cache_for_paths.get("skills", []):
if not isinstance(item, dict):
continue
name = str(item.get("name", "") or "").strip()
path = str(item.get("path", "") or "").strip().replace("\\", "/")
if name and path and _SKILL_NAME_RE.match(name):
sandbox_cached_paths[name] = path
sandbox_cached_descriptions: dict[str, str] = {}
cache_for_paths = self._load_sandbox_skills_cache()
for item in cache_for_paths.get("skills", []):
if not isinstance(item, dict):
continue
name = str(item.get("name", "") or "").strip()
path = str(item.get("path", "") or "").strip().replace("\\", "/")
if not name or not _SKILL_NAME_RE.match(name):
continue
sandbox_cached_descriptions[name] = str(item.get("description", "") or "")
if path:
sandbox_cached_paths[name] = path
for entry in sorted(Path(self.skills_root).iterdir()):
if not entry.is_dir():
@@ -246,6 +265,11 @@ class SkillManager:
description = _parse_frontmatter_description(content)
except Exception:
description = ""
sandbox_exists = (
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
)
source_type = "both" if sandbox_exists else "local_only"
source_label = "synced" if sandbox_exists else "local"
if runtime == "sandbox" and show_sandbox_path:
path_str = sandbox_cached_paths.get(skill_name) or (
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
@@ -258,6 +282,10 @@ class SkillManager:
description=description,
path=path_str,
active=active,
source_type=source_type,
source_label=source_label,
local_exists=True,
sandbox_exists=sandbox_exists,
)
if runtime == "sandbox":
@@ -278,22 +306,22 @@ class SkillManager:
modified = True
if active_only and not active:
continue
description = str(item.get("description", "") or "")
description = sandbox_cached_descriptions.get(skill_name, "")
if show_sandbox_path:
path_str = (
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
)
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
else:
path_str = str(item.get("path", "") or "")
path_str = sandbox_cached_paths.get(skill_name, "")
if not path_str:
path_str = (
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
)
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
skills_by_name[skill_name] = SkillInfo(
name=skill_name,
description=description,
path=path_str.replace("\\", "/"),
active=active,
source_type="sandbox_only",
source_label="sandbox_preset",
local_exists=False,
sandbox_exists=True,
)
if modified:
@@ -302,7 +330,27 @@ class SkillManager:
return [skills_by_name[name] for name in sorted(skills_by_name)]
def is_sandbox_only_skill(self, name: str) -> bool:
skill_dir = Path(self.skills_root) / name
skill_md_exists = (skill_dir / "SKILL.md").exists()
if skill_md_exists:
return False
cache = self._load_sandbox_skills_cache()
skills = cache.get("skills", [])
if not isinstance(skills, list):
return False
for item in skills:
if not isinstance(item, dict):
continue
if str(item.get("name", "")).strip() == name:
return True
return False
def set_skill_active(self, name: str, active: bool) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
"Sandbox preset skill cannot be enabled/disabled from local skill management."
)
config = self._load_config()
config.setdefault("skills", {})
config["skills"][name] = {"active": bool(active)}
@@ -318,8 +366,7 @@ class SkillManager:
item
for item in skills
if not (
isinstance(item, dict)
and str(item.get("name", "")).strip() == name
isinstance(item, dict) and str(item.get("name", "")).strip() == name
)
]
@@ -328,6 +375,11 @@ class SkillManager:
self._save_sandbox_skills_cache(cache)
def delete_skill(self, name: str) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
"Sandbox preset skill cannot be deleted from local skill management."
)
skill_dir = Path(self.skills_root) / name
if skill_dir.exists():
shutil.rmtree(skill_dir)
+59 -2
View File
@@ -1,9 +1,12 @@
import os
import re
import shutil
import traceback
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from quart import request
from quart import request, send_file
from astrbot.core import DEMO_MODE, logger
from astrbot.core.computer.computer_client import (
@@ -37,6 +40,9 @@ def _to_bool(value: Any, default: bool = False) -> bool:
return bool(value)
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
class SkillsRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle) -> None:
super().__init__(context)
@@ -44,6 +50,7 @@ class SkillsRoute(Route):
self.routes = {
"/skills": ("GET", self.get_skills),
"/skills/upload": ("POST", self.upload_skill),
"/skills/download": ("GET", self.download_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
"/skills/neo/candidates": ("GET", self.get_neo_candidates),
@@ -116,7 +123,8 @@ class SkillsRoute(Route):
"provider_settings", {}
)
runtime = provider_settings.get("computer_use_runtime", "local")
skills = SkillManager().list_skills(
skill_mgr = SkillManager()
skills = skill_mgr.list_skills(
active_only=False, runtime=runtime, show_sandbox_path=False
)
return (
@@ -124,6 +132,8 @@ class SkillsRoute(Route):
.ok(
{
"skills": [skill.__dict__ for skill in skills],
"runtime": runtime,
"sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(),
}
)
.__dict__
@@ -178,6 +188,53 @@ class SkillsRoute(Route):
except Exception:
logger.warning(f"Failed to remove temp skill file: {temp_path}")
async def download_skill(self):
try:
name = str(request.args.get("name") or "").strip()
if not name:
return Response().error("Missing skill name").__dict__
if not _SKILL_NAME_RE.match(name):
return Response().error("Invalid skill name").__dict__
skill_mgr = SkillManager()
if skill_mgr.is_sandbox_only_skill(name):
return (
Response()
.error(
"Sandbox preset skill cannot be downloaded from local skill files."
)
.__dict__
)
skill_dir = Path(skill_mgr.skills_root) / name
skill_md = skill_dir / "SKILL.md"
if not skill_dir.is_dir() or not skill_md.exists():
return Response().error("Local skill not found").__dict__
export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
export_dir.mkdir(parents=True, exist_ok=True)
zip_base = export_dir / name
zip_path = zip_base.with_suffix(".zip")
if zip_path.exists():
zip_path.unlink()
shutil.make_archive(
str(zip_base),
"zip",
root_dir=str(skill_mgr.skills_root),
base_dir=name,
)
return await send_file(
str(zip_path),
as_attachment=True,
attachment_filename=f"{name}.zip",
conditional=True,
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def update_skill(self):
if DEMO_MODE:
return (
@@ -23,8 +23,17 @@
</v-btn-toggle>
</v-row>
<div v-if="mode === 'local'" class="px-2 pb-2">
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
<small style="color: grey;">{{ tm("skills.runtimeHint") }}</small>
<v-alert
v-if="runtime === 'sandbox' && !sandboxCache.ready"
type="info"
variant="tonal"
density="comfortable"
border="start"
>
{{ tm("skills.sandboxDiscoveryPending") }}
</v-alert>
</div>
<div v-if="mode === 'neo' && !neoEnabled" class="px-3 pb-3">
@@ -42,27 +51,58 @@
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
</div>
<v-row v-else>
<v-col v-for="skill in skills" :key="skill.name" cols="12" md="6" lg="4" xl="3">
<v-row v-else align="stretch">
<v-col
v-for="skill in skills"
:key="skill.name"
cols="12"
md="6"
lg="4"
xl="3"
class="d-flex"
>
<item-card
:item="skill"
title-field="name"
enabled-field="active"
:loading="itemLoading[skill.name] || false"
:show-edit-button="false"
:disable-toggle="isSandboxPresetSkill(skill)"
:disable-delete="isSandboxPresetSkill(skill)"
@toggle-enabled="toggleSkill"
@delete="confirmDelete"
>
<template #item-details="{ item }">
<div class="text-caption text-medium-emphasis mb-2 skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
<div class="d-flex align-center mb-2 ga-2 flex-wrap">
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(item.source_type)"
>
{{ sourceTypeLabel(item.source_type) }}
</v-chip>
<div class="text-caption text-medium-emphasis skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
</div>
</div>
<div class="text-caption text-medium-emphasis">
<div class="text-caption text-medium-emphasis skill-path">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ item.path }}
</div>
</template>
<template #actions="{ item }">
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="itemLoading[item.name] || false || isSandboxPresetSkill(item)"
@click="downloadSkill(item)"
>
{{ tm("skills.download") }}
</v-btn>
</template>
</item-card>
</v-col>
</v-row>
@@ -288,6 +328,8 @@ export default {
const mode = ref("local");
const skills = ref([]);
const loading = ref(false);
const runtime = ref("local");
const sandboxCache = reactive({ ready: false, count: 0, updated_at: null });
const uploading = ref(false);
const uploadDialog = ref(false);
const uploadFile = ref(null);
@@ -357,10 +399,35 @@ export default {
const normalizeSkillsPayload = (res) => {
const payload = res?.data?.data || [];
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload)) {
runtime.value = "local";
sandboxCache.ready = false;
sandboxCache.count = 0;
sandboxCache.updated_at = null;
return payload;
}
runtime.value = payload.runtime || "local";
const cache = payload.sandbox_cache || {};
sandboxCache.ready = !!cache.ready;
sandboxCache.count = Number(cache.count || 0);
sandboxCache.updated_at = cache.updated_at || null;
return payload.skills || [];
};
const sourceTypeLabel = (sourceType) => {
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
if (sourceType === "both") return tm("skills.sourceBoth");
return tm("skills.sourceLocalOnly");
};
const sourceTypeColor = (sourceType) => {
if (sourceType === "sandbox_only") return "indigo";
if (sourceType === "both") return "success";
return "primary";
};
const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only";
const normalizeNeoItemsPayload = (res) => {
const payload = res?.data?.data || [];
if (Array.isArray(payload)) return payload;
@@ -417,6 +484,10 @@ export default {
};
const toggleSkill = async (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
const nextActive = !skill.active;
itemLoading[skill.name] = true;
try {
@@ -435,6 +506,10 @@ export default {
};
const confirmDelete = (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
skillToDelete.value = skill;
deleteDialog.value = true;
};
@@ -457,6 +532,34 @@ export default {
}
};
const downloadSkill = async (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
itemLoading[skill.name] = true;
try {
const res = await axios.get("/api/skills/download", {
params: { name: skill.name },
responseType: "blob",
});
const blob = new Blob([res.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${skill.name}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showMessage(tm("skills.downloadSuccess"), "success");
} catch (_err) {
showMessage(tm("skills.downloadFailed"), "error");
} finally {
itemLoading[skill.name] = false;
}
};
const fetchNeoCandidates = async () => {
const params = {
skill_key: neoFilters.skill_key || undefined,
@@ -685,6 +788,8 @@ export default {
mode,
skills,
loading,
runtime,
sandboxCache,
uploadDialog,
uploadFile,
uploading,
@@ -707,6 +812,7 @@ export default {
refreshCurrentMode,
fetchNeoData,
uploadSkill,
downloadSkill,
toggleSkill,
confirmDelete,
deleteSkill,
@@ -719,6 +825,9 @@ export default {
viewPayload,
deleteCandidate,
deleteRelease,
sourceTypeLabel,
sourceTypeColor,
isSandboxPresetSkill,
};
},
};
@@ -730,6 +839,16 @@ export default {
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 20px;
}
.skill-path {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
word-break: break-all;
}
.payload-preview {
+11 -2
View File
@@ -10,7 +10,7 @@
density="compact"
:model-value="getItemEnabled()"
:loading="loading"
:disabled="loading"
:disabled="loading || disableToggle"
v-bind="props"
@update:model-value="toggleEnabled"
></v-switch>
@@ -29,7 +29,7 @@
color="error"
size="small"
rounded="xl"
:disabled="loading"
:disabled="loading || disableDelete"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
@@ -108,6 +108,14 @@ export default {
showEditButton: {
type: Boolean,
default: true
},
disableToggle: {
type: Boolean,
default: false
},
disableDelete: {
type: Boolean,
default: false
}
},
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
@@ -132,6 +140,7 @@ export default {
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -220,6 +220,9 @@
"path": "Path",
"uploadSuccess": "Upload succeeded",
"uploadFailed": "Upload failed",
"download": "Download",
"downloadSuccess": "Download succeeded",
"downloadFailed": "Download failed",
"loadFailed": "Failed to load Skills",
"updateSuccess": "Updated successfully",
"updateFailed": "Update failed",
@@ -253,7 +256,12 @@
"neoPayloadFailed": "Failed to load payload",
"runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills.",
"neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo."
"neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo.",
"sourceLocalOnly": "Local Skill",
"sourceSandboxOnly": "Sandbox Preset Skill",
"sourceBoth": "Local + Sandbox",
"sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.",
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills."
},
"card": {
"actions": {
@@ -220,6 +220,9 @@
"path": "路径",
"uploadSuccess": "上传成功",
"uploadFailed": "上传失败",
"download": "下载",
"downloadSuccess": "下载成功",
"downloadFailed": "下载失败",
"loadFailed": "加载 Skills 失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
@@ -256,7 +259,12 @@
"neoPayloadFailed": "读取 Payload 失败",
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。",
"neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。"
"neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。",
"sourceLocalOnly": "本地 Skill",
"sourceSandboxOnly": "Sandbox 预置 Skill",
"sourceBoth": "本地 + Sandbox",
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。"
},
"card": {
"actions": {