统一技能安装与 Skills 市场接入链路。

新增公开安装工具与多来源 provider 支持,补齐管理端与目录加载逻辑,并同步更新相关测试与文档以保证可见性和路径安全。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-30 22:17:50 +08:00
parent d5e30542aa
commit 4d9232f3b3
66 changed files with 7369 additions and 1099 deletions

View file

@ -17,6 +17,13 @@ from oclaw.runtime.tools.skills_runtime.materialize_skill_tools import materiali
from oclaw.runtime.skills import SkillSpec, materialize_skills_from_tool_specs
logger = logging.getLogger(__name__)
# Tools hidden from model-facing registry to enforce auto-install only policy.
_MODEL_TOOLS_DENYLIST = frozenset(
{
"skill_market_install",
"skill_registry_install",
}
)
# Legacy export: some modules still import TOOL_FACTORIES. Tools are now intentionally
# restricted to a single safe builtin (`system_time`), so this is left empty.
@ -114,6 +121,16 @@ def _resolve_tool_conflicts(collected: list[tuple[str, ToolSpec]]) -> list[ToolS
def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
# Lazy imports to avoid circular dependency at module import time.
from oclaw.runtime.skill_installer import (
auto_install_skill_from_payload,
create_skill_from_template,
install_skill_from_registry_archive,
list_skills_with_status,
)
from oclaw.runtime.skills import default_skills_root
from oclaw.runtime.skills_market import get_market_adapter, normalize_skill_market_provider_setting
def _create_skill_handler(args: dict[str, Any]) -> dict[str, Any]:
out = create_skill_from_template(
store=store,
@ -126,14 +143,60 @@ def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
return {"ok": bool(out.ok), "name": out.name, "target_dir": out.target_dir, "detail": out.detail}
def _auto_install_skill_handler(args: dict[str, Any]) -> dict[str, Any]:
archive_url = str(args.get("archive_url") or "").strip()
slug = str(args.get("slug") or "").strip()
version = str(args.get("version") or "").strip() or None
overwrite = bool(args.get("overwrite"))
provider = normalize_skill_market_provider_setting(
str(args.get("provider") or store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "")
)
if not archive_url and slug:
try:
adapter = get_market_adapter(provider)
archive_url, _chosen_version = adapter.resolve_archive_url(slug=slug, version=version)
except Exception:
archive_url = ""
if archive_url:
out = install_skill_from_registry_archive(
store=store,
archive_url=archive_url,
overwrite=overwrite,
skills_root=default_skills_root() / "_workspace",
auto_bind=True,
)
return {
"ok": bool(out.ok),
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
"auto_enabled": bool(getattr(out, "auto_enabled", False)),
"binding_applied_roles": list(getattr(out, "binding_applied_roles", ()) or []),
"provider": provider,
}
payload = {
"name": str(args.get("name") or "").strip(),
"description": str(args.get("description") or "").strip(),
"body_markdown": str(args.get("body_markdown") or "").strip(),
"metadata_oclaw": dict(args.get("metadata_oclaw") or {}) if isinstance(args.get("metadata_oclaw"), dict) else {},
}
if not payload["name"]:
return {"ok": False, "error_code": "name_required", "error": "name_required"}
if not payload["description"]:
payload["description"] = f"{payload['name']} skill"
out = auto_install_skill_from_payload(store=store, payload=payload)
return {"ok": bool(out.ok), "name": out.name, "target_dir": out.target_dir, "detail": out.detail}
return {
"ok": bool(out.ok),
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
"auto_enabled": bool(getattr(out, "auto_enabled", False)),
"binding_applied_roles": list(getattr(out, "binding_applied_roles", ()) or []),
}
def _list_skills_handler(args: dict[str, Any]) -> dict[str, Any]:
del args
@ -146,9 +209,13 @@ def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
"description": {"type": "string"},
"body_markdown": {"type": "string"},
"metadata_oclaw": {"type": "object"},
"slug": {"type": "string"},
"provider": {"type": "string"},
"version": {"type": "string"},
"archive_url": {"type": "string"},
"overwrite": {"type": "boolean"},
},
"required": ["name", "description"],
"required": [],
}
return [
ToolSpec(
@ -167,7 +234,7 @@ def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
handler=_auto_install_skill_handler,
tags=frozenset({"skill", "oclaw", "installer"}),
risk_level="high",
timeout_s=20.0,
timeout_s=120.0,
),
ToolSpec(
name="skill_list",
@ -199,6 +266,9 @@ def materialize_tool_specs(
_ = factories
collected: list[tuple[str, ToolSpec]] = []
def _hidden_from_model(name: str) -> bool:
return str(name or "").strip() in _MODEL_TOOLS_DENYLIST
def _risk_allowed(spec: ToolSpec) -> bool:
# Optional safety gate for public tools.
# Default: only allow low risk public tools to be visible to all roles.
@ -213,6 +283,9 @@ def materialize_tool_specs(
for spec in list(materialize_public_tools()):
if not isinstance(spec, ToolSpec):
continue
if _hidden_from_model(str(spec.name or "")):
logger.info("public tool hidden from model registry: %s", str(spec.name or ""))
continue
if not _risk_allowed(spec):
logger.warning("public tool blocked by risk gate: %s", str(spec.name or ""))
continue
@ -225,6 +298,9 @@ def materialize_tool_specs(
for spec in materialize_tools_for_expert(str(expert or "").strip() or None):
if not isinstance(spec, ToolSpec):
continue
if _hidden_from_model(str(spec.name or "")):
logger.info("expert tool hidden from model registry: %s", str(spec.name or ""))
continue
collected.append(("expert", spec))
except Exception as exc:
logger.warning("expert tool load skipped: %s", exc)
@ -235,6 +311,9 @@ def materialize_tool_specs(
for spec in materialize_executable_skill_tools(store=store):
if not isinstance(spec, ToolSpec):
continue
if _hidden_from_model(str(spec.name or "")):
logger.info("skill runtime tool hidden from model registry: %s", str(spec.name or ""))
continue
collected.append(("skill_runtime", spec))
except Exception as exc:
logger.warning("skill runtime tool load skipped: %s", exc)
@ -260,6 +339,9 @@ def materialize_tool_specs(
path_policy_user_id=path_policy_user_id,
):
if isinstance(spec, ToolSpec):
if _hidden_from_model(str(spec.name or "")):
logger.info("mcp tool hidden from model registry: %s", str(spec.name or ""))
continue
collected.append(("mcp", spec))
except Exception as exc:
logger.warning("mcp tool load skipped: %s", exc)
@ -293,6 +375,9 @@ def materialize_tool_specs(
continue
tags_raw = row.get("tags")
tags = frozenset(str(x).strip() for x in (tags_raw or []) if str(x).strip())
if _hidden_from_model(name):
logger.info("plugin tool hidden from model registry: %s", name)
continue
collected.append((
"plugin",
ToolSpec(

View file

@ -172,6 +172,36 @@ def run_command_tool() -> ToolSpec:
command, normalized_cd_removed = _strip_leading_cd_chain(command)
command, command_rewritten = _rewrite_workspace_absolute_refs(command, workdir=workdir)
command, script_path_rewritten = _rewrite_python_script_arg(command, workdir=workdir)
def _external_skill_install_cli_blocked(raw_cmd: str) -> bool:
s = str(raw_cmd or "").strip()
if not s:
return False
low = s.lower()
if re.match(r"^\s*cocoloop(?:\.cmd|\.exe)?\s+install(?:\s|$)", s, flags=re.IGNORECASE):
return True
if re.match(r"^\s*clawhub(?:\.cmd|\.exe)?\s+install(?:\s|$)", s, flags=re.IGNORECASE):
return True
if re.search(r"\bnpx\b", low) and "clawhub" in low:
return True
if re.match(r"^\s*npm(?:\.cmd|\.exe)?\s+install\b", low) and "clawhub" in low:
return True
return False
if _external_skill_install_cli_blocked(str(command or "")):
return {
"ok": False,
"error_code": "skill_install_cli_blocked",
"error": "skill_install_cli_blocked",
"hint": "Oclaw has no shell skill installer. Use Admin POST /admin/api/skills/market/install or install-registry, or skill_auto_install.",
"command": command,
"cwd": str(workdir),
"normalized_cd_removed": bool(normalized_cd_removed),
"cwd_redirected_to_sandbox": bool(cwd_redirected_to_sandbox),
"command_rewritten": bool(command_rewritten),
"script_path_rewritten": bool(script_path_rewritten),
"original_command": original_command,
}
try:
os.makedirs(workdir, exist_ok=True)
except Exception:

View file

@ -0,0 +1,141 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.skill_installer import install_skill_from_registry_archive
from oclaw.runtime.skills import default_skills_root
from oclaw.runtime.skills_market import get_market_adapter, normalize_skill_market_provider_setting
from oclaw.runtime.tools.base import ToolSpec
def _store() -> SqliteStore:
return SqliteStore(db_path())
def _agent_workspace_skills_root() -> Path:
# Agent-origin installs are isolated under _workspace lane.
return default_skills_root() / "_workspace"
def skill_market_install_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
payload = args if isinstance(args, dict) else {}
slug = str(payload.get("slug") or "").strip()
if not slug:
return {"ok": False, "error_code": "slug_required", "error": "slug_required"}
version = str(payload.get("version") or "").strip() or None
overwrite = bool(payload.get("overwrite"))
store = _store()
provider_arg = str(payload.get("provider") or "").strip()
if provider_arg:
provider = normalize_skill_market_provider_setting(provider_arg)
else:
provider = normalize_skill_market_provider_setting(str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or ""))
try:
adapter = get_market_adapter(provider)
archive_url, chosen_version = adapter.resolve_archive_url(slug=slug, version=version)
except Exception as exc:
return {
"ok": False,
"error_code": "market_resolve_failed",
"error": f"market_resolve_failed:{type(exc).__name__}",
"provider": provider,
"slug": slug,
}
if not str(archive_url or "").strip():
return {
"ok": False,
"error_code": "archive_url_unavailable",
"error": "archive_url_unavailable",
"provider": provider,
"slug": slug,
}
out = install_skill_from_registry_archive(
store=store,
archive_url=str(archive_url),
overwrite=overwrite,
skills_root=_agent_workspace_skills_root(),
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
"provider": provider,
"slug": slug,
"version": str(chosen_version or version or ""),
}
return ToolSpec(
name="skill_market_install",
description="Install a skill from configured market by slug/version.",
parameters={
"type": "object",
"properties": {
"slug": {"type": "string"},
"provider": {"type": "string", "description": "Optional provider override: clawhub or cocoloop."},
"version": {"type": "string"},
"overwrite": {"type": "boolean"},
},
"required": ["slug"],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"skill", "installer", "market"}),
risk_level="medium",
timeout_s=120.0,
)
def skill_registry_install_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
payload = args if isinstance(args, dict) else {}
archive_url = str(payload.get("archive_url") or "").strip()
if not archive_url:
return {"ok": False, "error_code": "archive_url_required", "error": "archive_url_required"}
overwrite = bool(payload.get("overwrite"))
out = install_skill_from_registry_archive(
store=_store(),
archive_url=archive_url,
overwrite=overwrite,
skills_root=_agent_workspace_skills_root(),
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
return ToolSpec(
name="skill_registry_install",
description="Install a skill from archive URL (registry/market artifact).",
parameters={
"type": "object",
"properties": {
"archive_url": {"type": "string"},
"overwrite": {"type": "boolean"},
},
"required": ["archive_url"],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"skill", "installer", "registry"}),
risk_level="medium",
timeout_s=120.0,
)
__all__ = ["skill_market_install_tool", "skill_registry_install_tool"]

View file

@ -0,0 +1,187 @@
"""CocoLoop 技能商店 HTTP 客户端(与 ClawHub 并列,供 `skills_market` 使用)。"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
import httpx
def _strip_trailing_slash(url: str) -> str:
return str(url or "").strip().rstrip("/")
def _join_url(base: str, path: str) -> str:
b = _strip_trailing_slash(base)
p = str(path or "").strip()
if not p:
return b
if not p.startswith("/"):
p = "/" + p
return b + p
@dataclass(frozen=True)
class CocoloopConfig:
api_base_url: str = "https://api.cocoloop.com"
def load_cocoloop_config() -> CocoloopConfig:
base = str(os.getenv("AIA_COCOLOOP_API_BASE") or os.getenv("COCOLOOP_API_BASE") or "https://api.cocoloop.com").strip()
return CocoloopConfig(api_base_url=_strip_trailing_slash(base))
def _default_headers() -> dict[str, str]:
return {
"User-Agent": "Oclaw-SkillMarket/1.0 (+https://github.com/oclaw)",
"Accept": "application/json",
}
def _get_json(url: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
try:
with httpx.Client(timeout=12.0, follow_redirects=True) as c:
r = c.get(url, params=params or {}, headers=_default_headers())
if r.status_code != 200:
return {}
obj = r.json()
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def _list_items(cfg: CocoloopConfig, *, keyword: str, page: int, page_size: int) -> list[dict[str, Any]]:
url = _join_url(cfg.api_base_url, "/api/v1/store/skills")
blob = _get_json(
url,
params={
"page": max(1, int(page)),
"page_size": max(1, min(int(page_size), 100)),
"keyword": str(keyword or "").strip(),
"sort": "downloads",
},
)
data = blob.get("data") if isinstance(blob.get("data"), dict) else {}
items = data.get("items")
if not isinstance(items, list):
return []
return [x for x in items if isinstance(x, dict)]
def _normalize_list_row(raw: dict[str, Any]) -> dict[str, Any]:
slug = str(raw.get("name") or "").strip()
dl = str(raw.get("download_url") or "").strip()
ver = str(raw.get("version") or "").strip() or "latest"
return {
"source": "cocoloop",
"slug": slug,
"name": str(raw.get("subtitle") or raw.get("summary") or slug),
"description": str(raw.get("brief") or raw.get("summary") or raw.get("original_desc") or ""),
"version": ver,
"owner": str(raw.get("author") or ""),
"updatedAt": "",
"downloads": _parse_count(raw.get("downloads")),
"stars": _parse_count(raw.get("github_stars")),
"homepage": f"https://hub.cocoloop.cn/skills/{raw.get('id')}" if raw.get("id") else "",
"archiveUrl": dl,
"raw": raw,
}
def _parse_count(v: Any) -> int:
if isinstance(v, int):
return v
s = str(v or "").strip().lower().replace(",", "")
if not s:
return 0
mult = 1
if s.endswith("k"):
mult = 1000
s = s[:-1]
if s.endswith("m"):
mult = 1_000_000
s = s[:-1]
try:
return int(float(s) * mult)
except ValueError:
return 0
def search_store_skills(query: str, *, limit: int = 20, cfg: CocoloopConfig | None = None) -> list[dict[str, Any]]:
cfg = cfg or load_cocoloop_config()
lim = max(1, min(int(limit or 20), 100))
rows = _list_items(cfg, keyword=str(query or "").strip(), page=1, page_size=lim)
return [_normalize_list_row(r) for r in rows if str(r.get("name") or "").strip()]
def get_skill_detail_by_slug(slug: str, *, cfg: CocoloopConfig | None = None) -> dict[str, Any]:
"""按商店 `name`(slug)解析技能;必要时用数字 id 直查。"""
cfg = cfg or load_cocoloop_config()
s = str(slug or "").strip()
if not s:
return {}
if s.isdigit():
return _detail_from_id(cfg, int(s))
rows = _list_items(cfg, keyword=s, page=1, page_size=80)
want = s.lower()
hit: dict[str, Any] | None = None
for r in rows:
if str(r.get("name") or "").strip().lower() == want:
hit = r
break
if hit is None:
for r in rows:
nm = str(r.get("name") or "").strip().lower()
if want in nm or nm in want:
hit = r
break
if hit is None:
return {"slug": s, "source": "cocoloop"}
return _detail_from_list_row(cfg, hit)
def _detail_from_id(cfg: CocoloopConfig, skill_id: int) -> dict[str, Any]:
url = _join_url(cfg.api_base_url, f"/api/v1/store/skills/{int(skill_id)}")
blob = _get_json(url)
data = blob.get("data") if isinstance(blob.get("data"), dict) else {}
if not data:
return {"slug": str(skill_id), "source": "cocoloop"}
return _detail_from_list_row(cfg, data)
def _detail_from_list_row(cfg: CocoloopConfig, row: dict[str, Any]) -> dict[str, Any]:
slug = str(row.get("name") or "").strip()
dl = str(row.get("download_url") or "").strip()
if not dl and slug:
asset = str(row.get("asset_name") or f"{slug}.zip").strip()
if not asset.endswith(".zip"):
asset = f"{asset}.zip"
dl = f"https://dl.cocoloop.cn/bss/skills/{asset.lstrip('/')}"
ver = str(row.get("version") or "").strip() or "latest"
ver_clean = ver.lstrip("vV") if ver not in {"", "latest"} else ver
versions: list[dict[str, Any]] = [{"version": ver_clean or "latest", "changelog": "", "createdAt": "", "archiveUrl": dl, "raw": row}]
return {
"source": "cocoloop",
"slug": slug,
"name": str(row.get("subtitle") or row.get("summary") or slug),
"description": str(row.get("brief") or row.get("summary") or row.get("original_desc") or ""),
"owner": str(row.get("author") or ""),
"updatedAt": "",
"homepage": f"https://hub.cocoloop.cn/skills/{row.get('id')}" if row.get("id") else "",
"latestVersion": ver_clean if ver_clean else "latest",
"archiveUrl": dl,
"downloads": _parse_count(row.get("downloads")),
"stars": _parse_count(row.get("github_stars")),
"versions": versions,
"raw": row,
}
__all__ = [
"CocoloopConfig",
"load_cocoloop_config",
"search_store_skills",
"get_skill_detail_by_slug",
]