fix(webui): harden skill marketplace lifecycle

This commit is contained in:
Xubin Ren 2026-07-28 20:32:13 +08:00
parent 8a56eb06ad
commit c440695aef
21 changed files with 301 additions and 61 deletions

View File

@ -765,6 +765,151 @@ async def test_webui_skills_marketplace_routes_search_and_install(
await server_task await server_task
@pytest.mark.asyncio
async def test_webui_skill_install_rejects_overlapping_requests(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
started = asyncio.Event()
finish = asyncio.Event()
async def install(
source: str,
skill_id: str,
workspace: Path,
*,
provider: str,
version: str,
) -> dict[str, Any]:
started.set()
await finish.wait()
skill_dir = workspace / "skills" / skill_id
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
encoding="utf-8",
)
return {"installed": True, "already_installed": False, "name": skill_id}
install_mock = AsyncMock(side_effect=install)
monkeypatch.setattr("nanobot.webui.ws_http.install_marketplace_skill", install_mock)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=_free_port(),
)
token = channel.gateway.tokens.issue_api_token(300)
path = (
"/api/webui/skills/install"
"?source=acme%2Fagent-skills&skill=react-testing"
)
request = _FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
},
path=path,
)
first = asyncio.create_task(channel.gateway.http.dispatch(_LOCAL, request))
await started.wait()
overlapping = await channel.gateway.http.dispatch(_LOCAL, request)
assert overlapping.status_code == 409
assert "already in progress" in overlapping.body.decode()
assert install_mock.await_count == 1
finish.set()
completed = await first
assert completed.status_code == 200
assert install_mock.await_count == 1
@pytest.mark.asyncio
async def test_webui_skill_delete_remains_local_only(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
delete = MagicMock()
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
monkeypatch.setattr("nanobot.webui.ws_http.delete_webui_skill", delete)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=_free_port(),
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.dispatch(
_REMOTE,
_FakeReq(
{"Authorization": f"Bearer {token}"},
path="/api/webui/skills/delete?name=custom-skill",
),
)
assert response.status_code == 403
assert "remote skill deletion is disabled" in response.body.decode()
delete.assert_not_called()
@pytest.mark.asyncio
async def test_webui_skill_install_honors_remote_install_opt_in(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
async def install(
source: str,
skill_id: str,
workspace: Path,
*,
provider: str,
version: str,
) -> dict[str, Any]:
skill_dir = workspace / "skills" / skill_id
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
encoding="utf-8",
)
return {"installed": True, "already_installed": False, "name": skill_id}
monkeypatch.setattr(
"nanobot.webui.ws_http.install_marketplace_skill",
AsyncMock(side_effect=install),
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=_free_port(),
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.dispatch(
_REMOTE,
_FakeReq(
{"Authorization": f"Bearer {token}"},
path=(
"/api/webui/skills/install"
"?source=acme%2Fagent-skills&skill=react-testing"
),
),
)
assert response.status_code == 200
assert json.loads(response.body.decode())["last_action"]["name"] == "react-testing"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload( async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock, bus: MagicMock,

View File

@ -403,7 +403,7 @@ class ToolsConfig(Base):
"webuiAllowRemotePackageInstall", "webuiAllowRemotePackageInstall",
"webui_allow_remote_package_install", "webui_allow_remote_package_install",
), ),
) # allow non-local WebUI clients to install optional Python packages ) # allow non-local WebUI clients to install optional packages and agent skills
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import json import json
import shlex import shlex
import shutil import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -113,19 +113,25 @@ def delete_webui_skill(
target = skills_root / name target = skills_root / name
if target.parent != skills_root: if target.parent != skills_root:
raise SkillManagementError("invalid skill name") raise SkillManagementError("invalid skill name")
if target.is_symlink(): if not target.is_symlink() and not target.is_dir():
target.unlink()
elif target.is_dir():
shutil.rmtree(target)
else:
raise SkillManagementError("skill directory was not found", status=404) raise SkillManagementError("skill directory was not found", status=404)
config = load_config() config = load_config()
next_disabled = set(config.agents.defaults.disabled_skills) original_disabled = list(config.agents.defaults.disabled_skills)
next_disabled = set(original_disabled)
if name in next_disabled: if name in next_disabled:
next_disabled.remove(name) next_disabled.remove(name)
config.agents.defaults.disabled_skills = sorted(next_disabled) with tempfile.TemporaryDirectory(prefix=".nanobot-delete-", dir=skills_root) as staging:
save_config(config) staged_target = Path(staging) / name
target.replace(staged_target)
try:
if next_disabled != set(original_disabled):
config.agents.defaults.disabled_skills = sorted(next_disabled)
save_config(config)
except Exception:
config.agents.defaults.disabled_skills = original_disabled
staged_target.replace(target)
raise
disabled_skills.clear() disabled_skills.clear()
disabled_skills.update(next_disabled) disabled_skills.update(next_disabled)
return {"name": name, "enabled": False, "deleted": True} return {"name": name, "enabled": False, "deleted": True}

View File

@ -530,7 +530,6 @@ async def _install_skillhub_skill(
"name": skill_id, "name": skill_id,
"provider": _PROVIDER_SKILLHUB, "provider": _PROVIDER_SKILLHUB,
"version": version, "version": version,
"verified": bool(signature.get("signed")),
} }

View File

@ -199,6 +199,7 @@ class GatewayHTTPHandler:
self.skills_workspace_path = skills_workspace_path self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills if disabled_skills is not None else set() self.disabled_skills = disabled_skills if disabled_skills is not None else set()
self.skill_state_action = skill_state_action self.skill_state_action = skill_state_action
self._skill_install_lock = asyncio.Lock()
self.cron_service = cron_service self.cron_service = cron_service
self.local_trigger_store = local_trigger_store self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids self.cron_pending_job_ids = cron_pending_job_ids
@ -912,25 +913,28 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
if not self._allow_webui_package_install(connection, request): if not self._allow_webui_package_install(connection, request):
return _http_error(403, "remote skill installation is disabled") return _http_error(403, "remote skill installation is disabled")
if self._skill_install_lock.locked():
return _http_error(409, "another skill installation is already in progress")
query = _parse_query(request.path) query = _parse_query(request.path)
provider = _query_first(query, "provider") or "skills_sh" provider = _query_first(query, "provider") or "skills_sh"
source = _query_first(query, "source") or "" source = _query_first(query, "source") or ""
skill_id = _query_first(query, "skill") or "" skill_id = _query_first(query, "skill") or ""
version = _query_first(query, "version") or "" version = _query_first(query, "version") or ""
try: async with self._skill_install_lock:
action = await install_marketplace_skill( try:
source, action = await install_marketplace_skill(
skill_id, source,
self.skills_workspace_path, skill_id,
provider=provider, self.skills_workspace_path,
version=version, provider=provider,
) version=version,
except SkillsMarketplaceError as exc: )
return _http_error(exc.status, exc.message) except SkillsMarketplaceError as exc:
except Exception: return _http_error(exc.status, exc.message)
self._log.exception("skill installation failed") except Exception:
return _http_error(500, "skill installation failed") self._log.exception("skill installation failed")
return _http_error(500, "skill installation failed")
return _http_json_response({ return _http_json_response({
**webui_skills_payload( **webui_skills_payload(
self.skills_workspace_path, self.skills_workspace_path,
@ -983,7 +987,7 @@ class GatewayHTTPHandler:
) -> Response: ) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
if not self._allow_webui_package_install(connection, request): if not _is_local_browser_request(connection, request.headers):
return _http_error(403, "remote skill deletion is disabled") return _http_error(403, "remote skill deletion is disabled")
name = _query_first(_parse_query(request.path), "name") or "" name = _query_first(_parse_query(request.path), "name") or ""
try: try:

View File

@ -150,3 +150,30 @@ def test_delete_webui_skill_rejects_symlinked_skills_root(
assert exc_info.value.status == 403 assert exc_info.value.status == 403
assert (outside / "custom-skill" / "SKILL.md").is_file() assert (outside / "custom-skill" / "SKILL.md").is_file()
def test_delete_webui_skill_restores_directory_when_config_save_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill")
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
def fail_save(_config: object) -> None:
raise OSError("disk full")
monkeypatch.setattr("nanobot.webui.skills_api.save_config", fail_save)
disabled = {"custom-skill"}
with pytest.raises(OSError, match="disk full"):
delete_webui_skill(
tmp_path,
"custom-skill",
disabled_skills=disabled,
)
assert directory.is_dir()
assert (directory / "SKILL.md").is_file()
assert config.agents.defaults.disabled_skills == ["custom-skill"]
assert disabled == {"custom-skill"}

View File

@ -477,7 +477,6 @@ async def test_install_skillhub_skill_checks_fingerprint_and_extracts_safely(
"name": "ima-skills", "name": "ima-skills",
"provider": "skillhub", "provider": "skillhub",
"version": "1.1.8", "version": "1.1.8",
"verified": True,
} }
assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content

View File

@ -41,6 +41,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
).length; ).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null); const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
const [view, setView] = useState<"installed" | "discover">("installed"); const [view, setView] = useState<"installed" | "discover">("installed");
const [installingSkill, setInstallingSkill] = useState("");
const [installedQuery, setInstalledQuery] = useState(""); const [installedQuery, setInstalledQuery] = useState("");
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">( const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
"all", "all",
@ -209,7 +210,11 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
)} )}
</section> </section>
) : ( ) : (
<SkillsMarketplace installedSkills={skills} /> <SkillsMarketplace
installedSkills={skills}
installing={installingSkill}
onInstallingChange={setInstallingSkill}
/>
)} )}
<SkillDetailSheet <SkillDetailSheet

View File

@ -36,7 +36,15 @@ import type {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) { export function SkillsMarketplace({
installedSkills,
installing,
onInstallingChange,
}: {
installedSkills: SkillSummary[];
installing: string;
onInstallingChange: (skillId: string) => void;
}) {
const { token } = useClient(); const { token } = useClient();
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -48,7 +56,6 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
const [error, setError] = useState(""); const [error, setError] = useState("");
const [provider, setProvider] = useState<MarketplaceProvider>("all"); const [provider, setProvider] = useState<MarketplaceProvider>("all");
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null); const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
const [installing, setInstalling] = useState("");
const installedNames = useMemo( const installedNames = useMemo(
() => new Set(installedSkills.map((skill) => skill.name)), () => new Set(installedSkills.map((skill) => skill.name)),
[installedSkills], [installedSkills],
@ -149,7 +156,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
const install = async (skill: MarketplaceSkillSummary) => { const install = async (skill: MarketplaceSkillSummary) => {
setSelected(null); setSelected(null);
setInstalling(skill.id); onInstallingChange(skill.id);
setError(""); setError("");
try { try {
const payload = await installMarketplaceSkill( const payload = await installMarketplaceSkill(
@ -179,7 +186,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
}), }),
); );
} finally { } finally {
setInstalling(""); onInstallingChange("");
} }
}; };
@ -396,6 +403,7 @@ function MarketplaceSkillGroups({
grouped: boolean; grouped: boolean;
onSelect: (skill: MarketplaceSkillSummary) => void; onSelect: (skill: MarketplaceSkillSummary) => void;
}) { }) {
const { t } = useTranslation();
const providers: Array<Exclude<MarketplaceProvider, "all">> = [ const providers: Array<Exclude<MarketplaceProvider, "all">> = [
"skills_sh", "skills_sh",
"skillhub", "skillhub",
@ -425,7 +433,10 @@ function MarketplaceSkillGroups({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Open ${providerLabel(provider)}`} aria-label={t("settings.skills.marketplaceOpenProvider", {
provider: providerLabel(provider),
defaultValue: "Open {{provider}}",
})}
> >
<ExternalLink className="h-3.5 w-3.5" aria-hidden /> <ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a> </a>
@ -613,6 +624,9 @@ function providerUrl(provider: Exclude<MarketplaceProvider, "all">): string {
function TrendSparkline({ values }: { values?: number[] }) { function TrendSparkline({ values }: { values?: number[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
const trendLabel = t("settings.skills.marketplaceTrendLabel", {
defaultValue: "8-week install trend",
});
if (values === undefined) { if (values === undefined) {
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />; return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
@ -647,9 +661,9 @@ function TrendSparkline({ values }: { values?: number[] }) {
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${width} ${height}`}
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block" className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
role="img" role="img"
aria-label="8-week install trend" aria-label={trendLabel}
> >
<title>8-week install trend</title> <title>{trendLabel}</title>
<path d={area} fill="currentColor" opacity="0.06" /> <path d={area} fill="currentColor" opacity="0.06" />
<path <path
d={line} d={line}

View File

@ -837,6 +837,7 @@
"marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.", "marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
"marketplaceConfirmInstall": "Install skill", "marketplaceConfirmInstall": "Install skill",
"marketplaceOpen": "Open {{name}} on {{provider}}", "marketplaceOpen": "Open {{name}} on {{provider}}",
"marketplaceOpenProvider": "Open {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} installs / 24h", "marketplaceInstalls24h": "{{formattedCount}} installs / 24h",
"marketplaceInstalls": "{{formattedCount}} installs", "marketplaceInstalls": "{{formattedCount}} installs",
"marketplaceNpxRequired": "Node.js with npx is required", "marketplaceNpxRequired": "Node.js with npx is required",
@ -844,6 +845,7 @@
"marketplaceInstalled": "Installed", "marketplaceInstalled": "Installed",
"marketplaceInstall": "Install", "marketplaceInstall": "Install",
"marketplaceNoTrend": "No trend yet", "marketplaceNoTrend": "No trend yet",
"marketplaceTrendLabel": "8-week install trend",
"featured": "Agent skills", "featured": "Agent skills",
"empty": "No skills are available.", "empty": "No skills are available.",
"sourceWorkspace": "Custom", "sourceWorkspace": "Custom",

View File

@ -824,6 +824,7 @@
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.", "marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
"marketplaceConfirmInstall": "Instalar skill", "marketplaceConfirmInstall": "Instalar skill",
"marketplaceOpen": "Abrir {{name}} en {{provider}}", "marketplaceOpen": "Abrir {{name}} en {{provider}}",
"marketplaceOpenProvider": "Abrir {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h", "marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
"marketplaceInstalls": "{{formattedCount}} instalaciones", "marketplaceInstalls": "{{formattedCount}} instalaciones",
"marketplaceNpxRequired": "Se requiere Node.js con npx", "marketplaceNpxRequired": "Se requiere Node.js con npx",
@ -831,6 +832,7 @@
"marketplaceInstalled": "Instalado", "marketplaceInstalled": "Instalado",
"marketplaceInstall": "Instalar", "marketplaceInstall": "Instalar",
"marketplaceNoTrend": "Sin tendencia todavía", "marketplaceNoTrend": "Sin tendencia todavía",
"marketplaceTrendLabel": "Tendencia de instalaciones de 8 semanas",
"featured": "Habilidades del agente", "featured": "Habilidades del agente",
"empty": "No hay habilidades disponibles.", "empty": "No hay habilidades disponibles.",
"sourceWorkspace": "Personalizada", "sourceWorkspace": "Personalizada",

View File

@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "Cette compétence tierce provient de {{provider}} ({{source}}) et peut contenir des instructions ou des scripts exécutables.", "marketplaceConfirmDescription": "Cette compétence tierce provient de {{provider}} ({{source}}) et peut contenir des instructions ou des scripts exécutables.",
"marketplaceConfirmInstall": "Installer la compétence", "marketplaceConfirmInstall": "Installer la compétence",
"marketplaceOpen": "Ouvrir {{name}} sur {{provider}}", "marketplaceOpen": "Ouvrir {{name}} sur {{provider}}",
"marketplaceOpenProvider": "Ouvrir {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} installations / 24 h", "marketplaceInstalls24h": "{{formattedCount}} installations / 24 h",
"marketplaceInstalls": "{{formattedCount}} installations", "marketplaceInstalls": "{{formattedCount}} installations",
"marketplaceNpxRequired": "Node.js avec npx est requis", "marketplaceNpxRequired": "Node.js avec npx est requis",
@ -830,6 +831,7 @@
"marketplaceInstalled": "Installée", "marketplaceInstalled": "Installée",
"marketplaceInstall": "Installer", "marketplaceInstall": "Installer",
"marketplaceNoTrend": "Pas encore de tendance", "marketplaceNoTrend": "Pas encore de tendance",
"marketplaceTrendLabel": "Tendance des installations sur 8 semaines",
"featured": "Compétences agent", "featured": "Compétences agent",
"empty": "Aucune compétence disponible.", "empty": "Aucune compétence disponible.",
"sourceWorkspace": "Personnalisée", "sourceWorkspace": "Personnalisée",

View File

@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.", "marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
"marketplaceConfirmInstall": "Pasang skill", "marketplaceConfirmInstall": "Pasang skill",
"marketplaceOpen": "Buka {{name}} di {{provider}}", "marketplaceOpen": "Buka {{name}} di {{provider}}",
"marketplaceOpenProvider": "Buka {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam", "marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
"marketplaceInstalls": "{{formattedCount}} pemasangan", "marketplaceInstalls": "{{formattedCount}} pemasangan",
"marketplaceNpxRequired": "Node.js dengan npx diperlukan", "marketplaceNpxRequired": "Node.js dengan npx diperlukan",
@ -830,6 +831,7 @@
"marketplaceInstalled": "Terpasang", "marketplaceInstalled": "Terpasang",
"marketplaceInstall": "Pasang", "marketplaceInstall": "Pasang",
"marketplaceNoTrend": "Belum ada tren", "marketplaceNoTrend": "Belum ada tren",
"marketplaceTrendLabel": "Tren pemasangan 8 minggu",
"featured": "Skill agent", "featured": "Skill agent",
"empty": "Tidak ada skill yang tersedia.", "empty": "Tidak ada skill yang tersedia.",
"sourceWorkspace": "Kustom", "sourceWorkspace": "Kustom",

View File

@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}{{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。", "marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}{{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。",
"marketplaceConfirmInstall": "スキルをインストール", "marketplaceConfirmInstall": "スキルをインストール",
"marketplaceOpen": "{{provider}} で {{name}} を開く", "marketplaceOpen": "{{provider}} で {{name}} を開く",
"marketplaceOpenProvider": "{{provider}} を開く",
"marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール", "marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール",
"marketplaceInstalls": "{{formattedCount}} 回インストール", "marketplaceInstalls": "{{formattedCount}} 回インストール",
"marketplaceNpxRequired": "npx を含む Node.js が必要です", "marketplaceNpxRequired": "npx を含む Node.js が必要です",
@ -830,6 +831,7 @@
"marketplaceInstalled": "インストール済み", "marketplaceInstalled": "インストール済み",
"marketplaceInstall": "インストール", "marketplaceInstall": "インストール",
"marketplaceNoTrend": "トレンドなし", "marketplaceNoTrend": "トレンドなし",
"marketplaceTrendLabel": "8週間のインストール推移",
"featured": "エージェントスキル", "featured": "エージェントスキル",
"empty": "利用可能なスキルはありません。", "empty": "利用可能なスキルはありません。",
"sourceWorkspace": "カスタム", "sourceWorkspace": "カスタム",

View File

@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.", "marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.",
"marketplaceConfirmInstall": "스킬 설치", "marketplaceConfirmInstall": "스킬 설치",
"marketplaceOpen": "{{provider}}에서 {{name}} 열기", "marketplaceOpen": "{{provider}}에서 {{name}} 열기",
"marketplaceOpenProvider": "{{provider}} 열기",
"marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치", "marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치",
"marketplaceInstalls": "{{formattedCount}}회 설치", "marketplaceInstalls": "{{formattedCount}}회 설치",
"marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다", "marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다",
@ -830,6 +831,7 @@
"marketplaceInstalled": "설치됨", "marketplaceInstalled": "설치됨",
"marketplaceInstall": "설치", "marketplaceInstall": "설치",
"marketplaceNoTrend": "추세 없음", "marketplaceNoTrend": "추세 없음",
"marketplaceTrendLabel": "8주 설치 추이",
"featured": "에이전트 스킬", "featured": "에이전트 스킬",
"empty": "사용 가능한 스킬이 없습니다.", "empty": "사용 가능한 스킬이 없습니다.",
"sourceWorkspace": "사용자 지정", "sourceWorkspace": "사용자 지정",

View File

@ -837,6 +837,7 @@
"marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.", "marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
"marketplaceConfirmInstall": "Instalar skill", "marketplaceConfirmInstall": "Instalar skill",
"marketplaceOpen": "Abrir {{name}} no {{provider}}", "marketplaceOpen": "Abrir {{name}} no {{provider}}",
"marketplaceOpenProvider": "Abrir {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h", "marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
"marketplaceInstalls": "{{formattedCount}} instalações", "marketplaceInstalls": "{{formattedCount}} instalações",
"marketplaceNpxRequired": "Node.js com npx é necessário", "marketplaceNpxRequired": "Node.js com npx é necessário",
@ -844,6 +845,7 @@
"marketplaceInstalled": "Instalada", "marketplaceInstalled": "Instalada",
"marketplaceInstall": "Instalar", "marketplaceInstall": "Instalar",
"marketplaceNoTrend": "Ainda sem tendência", "marketplaceNoTrend": "Ainda sem tendência",
"marketplaceTrendLabel": "Tendência de instalações em 8 semanas",
"featured": "Skills do agente", "featured": "Skills do agente",
"empty": "Nenhuma skill disponível.", "empty": "Nenhuma skill disponível.",
"sourceWorkspace": "Personalizada", "sourceWorkspace": "Personalizada",

View File

@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.", "marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
"marketplaceConfirmInstall": "Cài đặt kỹ năng", "marketplaceConfirmInstall": "Cài đặt kỹ năng",
"marketplaceOpen": "Mở {{name}} trên {{provider}}", "marketplaceOpen": "Mở {{name}} trên {{provider}}",
"marketplaceOpenProvider": "Mở {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ", "marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ",
"marketplaceInstalls": "{{formattedCount}} lượt cài đặt", "marketplaceInstalls": "{{formattedCount}} lượt cài đặt",
"marketplaceNpxRequired": "Cần Node.js có npx", "marketplaceNpxRequired": "Cần Node.js có npx",
@ -830,6 +831,7 @@
"marketplaceInstalled": "Đã cài đặt", "marketplaceInstalled": "Đã cài đặt",
"marketplaceInstall": "Cài đặt", "marketplaceInstall": "Cài đặt",
"marketplaceNoTrend": "Chưa có xu hướng", "marketplaceNoTrend": "Chưa có xu hướng",
"marketplaceTrendLabel": "Xu hướng lượt cài đặt trong 8 tuần",
"featured": "Kỹ năng agent", "featured": "Kỹ năng agent",
"empty": "Không có kỹ năng nào khả dụng.", "empty": "Không có kỹ năng nào khả dụng.",
"sourceWorkspace": "Tùy chỉnh", "sourceWorkspace": "Tùy chỉnh",

View File

@ -837,6 +837,7 @@
"marketplaceConfirmDescription": "此第三方技能来自 {{provider}}{{source}}),其中可能包含操作指令或可执行脚本。", "marketplaceConfirmDescription": "此第三方技能来自 {{provider}}{{source}}),其中可能包含操作指令或可执行脚本。",
"marketplaceConfirmInstall": "安装技能", "marketplaceConfirmInstall": "安装技能",
"marketplaceOpen": "在 {{provider}} 中打开 {{name}}", "marketplaceOpen": "在 {{provider}} 中打开 {{name}}",
"marketplaceOpenProvider": "打开 {{provider}}",
"marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次", "marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次",
"marketplaceInstalls": "安装 {{formattedCount}} 次", "marketplaceInstalls": "安装 {{formattedCount}} 次",
"marketplaceNpxRequired": "需要安装带有 npx 的 Node.js", "marketplaceNpxRequired": "需要安装带有 npx 的 Node.js",
@ -844,6 +845,7 @@
"marketplaceInstalled": "已安装", "marketplaceInstalled": "已安装",
"marketplaceInstall": "安装", "marketplaceInstall": "安装",
"marketplaceNoTrend": "暂无趋势", "marketplaceNoTrend": "暂无趋势",
"marketplaceTrendLabel": "近 8 周安装趋势",
"featured": "Agent 技能", "featured": "Agent 技能",
"empty": "暂无可用技能。", "empty": "暂无可用技能。",
"sourceWorkspace": "自定义", "sourceWorkspace": "自定义",

View File

@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "此第三方技能來自 {{provider}}{{source}}),其中可能包含操作指示或可執行腳本。", "marketplaceConfirmDescription": "此第三方技能來自 {{provider}}{{source}}),其中可能包含操作指示或可執行腳本。",
"marketplaceConfirmInstall": "安裝技能", "marketplaceConfirmInstall": "安裝技能",
"marketplaceOpen": "在 {{provider}} 開啟 {{name}}", "marketplaceOpen": "在 {{provider}} 開啟 {{name}}",
"marketplaceOpenProvider": "開啟 {{provider}}",
"marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次", "marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次",
"marketplaceInstalls": "安裝 {{formattedCount}} 次", "marketplaceInstalls": "安裝 {{formattedCount}} 次",
"marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js", "marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js",
@ -830,6 +831,7 @@
"marketplaceInstalled": "已安裝", "marketplaceInstalled": "已安裝",
"marketplaceInstall": "安裝", "marketplaceInstall": "安裝",
"marketplaceNoTrend": "暫無趨勢", "marketplaceNoTrend": "暫無趨勢",
"marketplaceTrendLabel": "近 8 週安裝趨勢",
"featured": "Agent 技能", "featured": "Agent 技能",
"empty": "目前沒有可用的技能。", "empty": "目前沒有可用的技能。",
"sourceWorkspace": "自訂", "sourceWorkspace": "自訂",

View File

@ -37,7 +37,11 @@ function mockFetchRoutes(routes: Record<string, unknown>): void {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
const body = routes[String(input)]; const route = routes[String(input)];
const body =
typeof route === "function"
? await (route as () => unknown | Promise<unknown>)()
: route;
return body === undefined return body === undefined
? ({ ok: false, status: 404, json: async () => ({}) } as Response) ? ({ ok: false, status: 404, json: async () => ({}) } as Response)
: jsonResponse(body); : jsonResponse(body);
@ -597,6 +601,26 @@ describe("App layout", () => {
}); });
it("discovers and installs a skill from skills.sh", async () => { it("discovers and installs a skill from skills.sh", async () => {
let finishInstall!: (value: unknown) => void;
const pendingInstall = new Promise<unknown>((resolve) => {
finishInstall = resolve;
});
const installedPayload = {
skills: [
{
name: "react-testing",
description: "Test React apps.",
source: "workspace",
available: true,
},
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
last_action: {
installed: true,
already_installed: false,
name: "react-testing",
},
};
mockFetchRoutes({ mockFetchRoutes({
"/api/settings": baseSettingsPayload(), "/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }, "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
@ -683,22 +707,8 @@ describe("App layout", () => {
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": { "/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] }, trends: { "acme/agent-skills/react-testing": [] },
}, },
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing": { "/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
skills: [ () => pendingInstall,
{
name: "react-testing",
description: "Test React apps.",
source: "workspace",
available: true,
},
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
last_action: {
installed: true,
already_installed: false,
name: "react-testing",
},
},
}); });
render(<App />); render(<App />);
@ -751,10 +761,20 @@ describe("App layout", () => {
}), }),
); );
}); });
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
fireEvent.click(screen.getByRole("tab", { name: "Discover" }));
expect( expect(
await screen.findByRole("button", { name: "Installed React Testing" }), await screen.findByRole("button", { name: "Install find-skills" }),
).toBeDisabled(); ).toBeDisabled();
await act(async () => {
finishInstall(installedPayload);
await pendingInstall;
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Install find-skills" })).toBeEnabled();
});
fireEvent.click(screen.getByRole("tab", { name: "Installed" })); fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("react-testing")).toBeInTheDocument(); expect(screen.getByText("react-testing")).toBeInTheDocument();
}); });
@ -1996,9 +2016,8 @@ describe("App layout", () => {
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument(); expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument(); expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument();
expect(screen.queryByText("Not configured")).not.toBeInTheDocument(); expect(screen.queryByText("Not configured")).not.toBeInTheDocument();
const clickProviderRow = (label: string) => { const clickProviderRow = async (label: string) => {
const providerLabel = screen const providerLabel = (await screen.findAllByText(label))
.getAllByText(label)
.find((element) => element.className.includes("font-semibold")); .find((element) => element.className.includes("font-semibold"));
expect(providerLabel).toBeTruthy(); expect(providerLabel).toBeTruthy();
fireEvent.click(providerLabel!); fireEvent.click(providerLabel!);
@ -2009,21 +2028,21 @@ describe("App layout", () => {
); );
fireEvent.click(await screen.findByRole("menuitem", { name: label })); fireEvent.click(await screen.findByRole("menuitem", { name: label }));
}; };
clickProviderRow("OpenAI"); await clickProviderRow("OpenAI");
fireEvent.click(screen.getByRole("button", { name: "Edit" })); fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), { fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
target: { value: "unsaved-openai-key" }, target: { value: "unsaved-openai-key" },
}); });
clickProviderRow("OpenAI"); await clickProviderRow("OpenAI");
await chooseProvider("OpenRouter"); await chooseProvider("OpenRouter");
clickProviderRow("OpenRouter"); await clickProviderRow("OpenRouter");
clickProviderRow("OpenAI"); await clickProviderRow("OpenAI");
expect(screen.getByText("open••••-key")).toBeInTheDocument(); expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
clickProviderRow("OpenAI"); await clickProviderRow("OpenAI");
await chooseProvider("Ant Ling"); await chooseProvider("Ant Ling");
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument(); expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
clickProviderRow("Ant Ling"); await clickProviderRow("Ant Ling");
await chooseProvider("Atomic Chat"); await chooseProvider("Atomic Chat");
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();

View File

@ -124,6 +124,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.skills.marketplaceConfirmDescription", "settings.skills.marketplaceConfirmDescription",
"settings.skills.marketplaceConfirmInstall", "settings.skills.marketplaceConfirmInstall",
"settings.skills.marketplaceOpen", "settings.skills.marketplaceOpen",
"settings.skills.marketplaceOpenProvider",
"settings.skills.marketplaceInstalls24h", "settings.skills.marketplaceInstalls24h",
"settings.skills.marketplaceInstalls", "settings.skills.marketplaceInstalls",
"settings.skills.marketplaceNpxRequired", "settings.skills.marketplaceNpxRequired",
@ -131,6 +132,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.skills.marketplaceInstalled", "settings.skills.marketplaceInstalled",
"settings.skills.marketplaceInstall", "settings.skills.marketplaceInstall",
"settings.skills.marketplaceNoTrend", "settings.skills.marketplaceNoTrend",
"settings.skills.marketplaceTrendLabel",
"settings.nanobotFeatures.disable", "settings.nanobotFeatures.disable",
"settings.nanobotFeatures.ready", "settings.nanobotFeatures.ready",
"settings.nanobotFeatures.missingDependency", "settings.nanobotFeatures.missingDependency",