oclaw/runtime/skill_installer.py
oliver 5f68afa963 为自写技能增加隔离目录与默认可用通道。
自动安装/创建的 workspace 技能统一落在 _workspace 目录,并在启用受信开关时自动启用和绑定到 manager+各专家;同时补充 API 返回状态与回归测试。

Made-with: Cursor
2026-04-29 20:53:11 +08:00

583 lines
22 KiB
Python

from __future__ import annotations
import json
import shutil
import tarfile
import tempfile
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.runtime.skills_market import get_market_adapter
from oclaw.runtime.skill_role_binding import (
SKILL_ROLE_BINDING_ENABLED_SETTING,
SKILL_ROLE_BINDING_KEY,
normalize_skill_role_binding,
ordered_binding_roles,
)
from oclaw.runtime.skills import (
default_skills_root,
discover_workspace_skill_manifests,
load_skill_manifest,
)
_DISABLED_SKILLS_KEY = "AIA_SKILL_DISABLED_NAMES"
_AUTO_INSTALL_KEY = "AIA_SKILL_AUTO_INSTALL_ENABLED"
_AUTO_ENABLE_TRUSTED_KEY = "AIA_SKILL_AUTO_ENABLE_TRUSTED"
@dataclass(frozen=True)
class SkillInstallResult:
ok: bool
name: str
target_dir: str
detail: str = ""
error_code: str = ""
retryable: bool = False
auto_enabled: bool = False
binding_applied_roles: tuple[str, ...] = ()
def _classify_install_detail(detail: str) -> tuple[str, bool]:
d = str(detail or "").strip().lower()
if not d:
return ("unknown", False)
if d.startswith("download_failed") or d.startswith("extract_failed"):
return ("transport_or_extract_error", True)
if d in {"archive_too_large", "empty_archive", "unsupported_archive_type", "archive_url_required", "unsupported_url_scheme"}:
return ("invalid_archive", False)
if d in {"already_exists"}:
return ("already_exists", False)
if d.startswith("unsafe_") or d.startswith("skill_md_missing") or d.startswith("manifest_parse_failed"):
return ("invalid_skill_package", False)
if d.startswith("rollback_after_error"):
return ("runtime_error", True)
if d in {"installed", "created", "removed"}:
return ("ok", False)
return ("unknown", False)
def _truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
def skill_auto_install_enabled(store: Any) -> bool:
try:
raw = str(store.get_setting(_AUTO_INSTALL_KEY) or "").strip()
except Exception:
raw = ""
if not raw:
return True
return _truthy(raw)
def skill_auto_enable_trusted_enabled(store: Any) -> bool:
try:
raw = str(store.get_setting(_AUTO_ENABLE_TRUSTED_KEY) or "").strip()
except Exception:
raw = ""
# Default ON for workspace/auto-install lane; tenant can disable explicitly.
if not raw:
return True
return _truthy(raw)
def _get_disabled_names(store: Any) -> set[str]:
try:
raw = str(store.get_setting(_DISABLED_SKILLS_KEY) or "").strip()
except Exception:
raw = ""
if not raw:
return set()
try:
arr = json.loads(raw)
if isinstance(arr, list):
return {str(x).strip() for x in arr if str(x).strip()}
except Exception:
pass
return set()
def _set_disabled_names(store: Any, names: set[str]) -> None:
vals = sorted({str(x).strip() for x in names if str(x).strip()})
store.set_setting(_DISABLED_SKILLS_KEY, json.dumps(vals, ensure_ascii=False))
def _apply_auto_enable_binding(*, store: Any, skill_name: str, skills_root: str | Path | None = None) -> tuple[bool, tuple[str, ...]]:
nm = str(skill_name or "").strip()
if not nm:
return False, ()
if not skill_auto_enable_trusted_enabled(store):
return False, ()
roles = ordered_binding_roles()
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests(skills_root) if str(m.name or "").strip()}
# If discovery lags for any reason, still include the newly created skill as valid candidate.
valid.add(nm)
try:
raw = str(store.get_setting(SKILL_ROLE_BINDING_KEY) or "").strip()
mapping_raw = json.loads(raw) if raw else {}
except Exception:
mapping_raw = {}
mapping = normalize_skill_role_binding(
mapping_raw=mapping_raw if isinstance(mapping_raw, dict) else {},
valid_skill_names=valid,
available_roles=roles,
)
changed = False
for role in roles:
cur = list(mapping.get(role) or [])
if nm not in cur:
cur.append(nm)
mapping[role] = cur
changed = True
if changed:
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
# Ensure role filter is active when we auto-attach to all roles.
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1")
return True, tuple(roles)
def list_skills_with_status(*, store: Any, skills_root: str | Path | None = None) -> list[dict[str, Any]]:
disabled = _get_disabled_names(store)
out: list[dict[str, Any]] = []
for m in discover_workspace_skill_manifests(skills_root):
runtime = dict(m.runtime or {}) if isinstance(m.runtime, dict) else {}
out.append(
{
"name": m.name,
"description": m.description,
"skill_dir": m.skill_dir,
"skill_file": m.skill_file,
"enabled": m.name not in disabled,
"install_specs": [dict(id=s.id, kind=s.kind, payload=s.payload) for s in m.install],
"metadata_oclaw": dict(m.metadata_oclaw),
"runtime": runtime,
"executable": bool(runtime),
}
)
return out
def set_skill_enabled(*, store: Any, skill_name: str, enabled: bool) -> None:
name = str(skill_name or "").strip()
if not name:
return
disabled = _get_disabled_names(store)
if enabled:
disabled.discard(name)
else:
disabled.add(name)
_set_disabled_names(store, disabled)
def uninstall_skill(
*,
store: Any,
skill_name: str,
skills_root: str | Path | None = None,
) -> SkillInstallResult:
name = str(skill_name or "").strip()
if not name:
ec, rt = _classify_install_detail("name_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
candidates = [root / name, root / "_workspace" / name]
target = next((p for p in candidates if p.exists() and p.is_dir()), None)
if target is None:
ec, rt = _classify_install_detail("not_found")
return SkillInstallResult(ok=False, name=name, target_dir="", detail="not_found", error_code=ec, retryable=rt)
try:
shutil.rmtree(target)
except Exception as exc:
ec, rt = _classify_install_detail("runtime_error")
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail=f"remove_failed:{type(exc).__name__}", error_code=ec, retryable=rt)
set_skill_enabled(store=store, skill_name=name, enabled=True)
ec, rt = _classify_install_detail("removed")
return SkillInstallResult(ok=True, name=name, target_dir=str(target), detail="removed", error_code=ec, retryable=rt)
def scan_skill_source_dir(src_dir: str | Path) -> tuple[bool, str]:
src = Path(src_dir).resolve()
if not src.exists() or not src.is_dir():
return False, "source_dir_not_found"
skill_md = src / "SKILL.md"
if not skill_md.exists() or not skill_md.is_file():
return False, "skill_md_missing"
for p in src.rglob("*"):
if not p.is_file():
continue
nm = p.name.lower()
if nm.endswith((".exe", ".dll", ".bat", ".cmd", ".ps1")):
return False, f"unsafe_file_detected:{p.name}"
return True, "ok"
def _safe_archive_members(paths: list[str]) -> bool:
for p in paths:
v = str(p or "").replace("\\", "/")
if not v or v.startswith("/") or ".." in v.split("/"):
return False
return True
def _extract_archive_to_temp(archive_path: Path) -> tuple[bool, str, Path | None]:
temp_dir = Path(tempfile.mkdtemp(prefix="skill_archive_"))
try:
if zipfile.is_zipfile(archive_path):
with zipfile.ZipFile(archive_path, "r") as zf:
members = [str(x.filename or "") for x in zf.infolist()]
if not _safe_archive_members(members):
return False, "unsafe_archive_path", None
zf.extractall(temp_dir)
return True, "ok", temp_dir
if tarfile.is_tarfile(archive_path):
with tarfile.open(archive_path, "r:*") as tf:
members = [str(x.name or "") for x in tf.getmembers()]
if not _safe_archive_members(members):
return False, "unsafe_archive_path", None
tf.extractall(temp_dir)
return True, "ok", temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
return False, "unsupported_archive_type", None
except Exception as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
return False, f"extract_failed:{type(exc).__name__}", None
def _resolve_registry_archive_url(url: str) -> str:
raw = str(url or "").strip()
if not raw:
return raw
parsed = urllib.parse.urlparse(raw)
if parsed.scheme not in {"http", "https"}:
return raw
host = str(parsed.netloc or "").strip().lower()
if host not in {"clawhub.ai", "www.clawhub.ai"}:
return raw
segs = [s for s in str(parsed.path or "").split("/") if s]
if not segs:
return raw
# Clawhub page URLs: https://clawhub.ai/<author>/<slug>
# Resolve via clawhub client first (matches test + avoids adapter drift).
try:
if len(segs) >= 2:
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail
slug = str(segs[-1] or "").strip()
if slug:
detail = get_skill_detail(slug)
archive_url = str((detail or {}).get("archiveUrl") or "").strip()
if archive_url:
return archive_url
except Exception:
pass
candidates: list[str] = []
if len(segs) >= 1:
candidates.append(segs[-1])
if len(segs) >= 2:
candidates.append(f"{segs[0]}/{segs[1]}")
try:
adapter = get_market_adapter("clawhub")
for slug in candidates:
archive_url, _ = adapter.resolve_archive_url(slug=slug, version=None)
if archive_url:
return archive_url
return raw
except Exception:
return raw
def install_skill_from_local_dir(
*,
store: Any,
source_dir: str | Path,
overwrite: bool = False,
skills_root: str | Path | None = None,
) -> SkillInstallResult:
src = Path(source_dir).resolve()
ok, detail = scan_skill_source_dir(src)
if not ok:
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
manifest = load_skill_manifest(src)
if not manifest:
ec, rt = _classify_install_detail("manifest_parse_failed")
return SkillInstallResult(ok=False, name="", target_dir="", detail="manifest_parse_failed", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
root.mkdir(parents=True, exist_ok=True)
safe_name = str(manifest.name).strip()
target = root / safe_name
if target.exists():
if not overwrite:
ec, rt = _classify_install_detail("already_exists")
return SkillInstallResult(ok=False, name=manifest.name, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
shutil.rmtree(target)
shutil.copytree(src, target)
set_skill_enabled(store=store, skill_name=manifest.name, enabled=True)
ec, rt = _classify_install_detail("installed")
return SkillInstallResult(ok=True, name=manifest.name, target_dir=str(target), detail="installed", error_code=ec, retryable=rt)
def install_skill_from_registry_archive(
*,
store: Any,
archive_url: str,
overwrite: bool = False,
skills_root: str | Path | None = None,
) -> SkillInstallResult:
url = str(archive_url or "").strip()
if not url:
ec, rt = _classify_install_detail("archive_url_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="archive_url_required", error_code=ec, retryable=rt)
url = _resolve_registry_archive_url(url)
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"https", "http", "file"}:
ec, rt = _classify_install_detail("unsupported_url_scheme")
return SkillInstallResult(ok=False, name="", target_dir="", detail="unsupported_url_scheme", error_code=ec, retryable=rt)
tmp_file = Path(tempfile.mkstemp(prefix="skill_pkg_", suffix=".bin")[1])
try:
req = urllib.request.Request(
url,
headers={
"User-Agent": "Oclaw-SkillInstaller/1.0 (+https://clawhub.ai)",
"Accept": "*/*",
},
)
with urllib.request.urlopen(req, timeout=20) as resp:
data = resp.read()
if not data:
ec, rt = _classify_install_detail("empty_archive")
return SkillInstallResult(ok=False, name="", target_dir="", detail="empty_archive", error_code=ec, retryable=rt)
if len(data) > 50 * 1024 * 1024:
ec, rt = _classify_install_detail("archive_too_large")
return SkillInstallResult(ok=False, name="", target_dir="", detail="archive_too_large", error_code=ec, retryable=rt)
tmp_file.write_bytes(data)
ok, detail, extracted = _extract_archive_to_temp(tmp_file)
if not ok or extracted is None:
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
candidates: list[Path] = []
if (extracted / "SKILL.md").exists():
candidates.append(extracted)
for p in extracted.rglob("SKILL.md"):
if p.is_file():
candidates.append(p.parent)
if not candidates:
ec, rt = _classify_install_detail("skill_md_missing")
return SkillInstallResult(ok=False, name="", target_dir="", detail="skill_md_missing", error_code=ec, retryable=rt)
chosen = sorted(candidates, key=lambda x: len(x.parts))[0]
return install_skill_from_local_dir(store=store, source_dir=chosen, overwrite=overwrite, skills_root=skills_root)
except Exception as exc:
detail = f"download_failed:{type(exc).__name__}"
code = getattr(exc, "code", None)
if isinstance(code, int):
detail = f"download_failed:http_{code}"
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
finally:
try:
if tmp_file.exists():
tmp_file.unlink()
except Exception:
pass
try:
if "extracted" in locals() and isinstance(extracted, Path) and extracted.exists():
shutil.rmtree(extracted, ignore_errors=True)
except Exception:
pass
def create_skill_from_template(
*,
store: Any,
name: str,
description: str,
body_markdown: str = "",
metadata_oclaw: dict[str, Any] | None = None,
skills_root: str | Path | None = None,
overwrite: bool = False,
) -> SkillInstallResult:
nm = str(name or "").strip()
if not nm:
ec, rt = _classify_install_detail("name_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
root.mkdir(parents=True, exist_ok=True)
target = root / nm
if target.exists() and not overwrite:
ec, rt = _classify_install_detail("already_exists")
return SkillInstallResult(ok=False, name=nm, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
target.mkdir(parents=True, exist_ok=True)
oc = dict(metadata_oclaw or {})
front = {
"name": nm,
"description": str(description or f"{nm} skill"),
"user-invocable": "true",
"disable-model-invocation": "false",
"metadata": {"oclaw": oc},
}
text = (
"---\n"
f"name: {front['name']}\n"
f"description: {front['description']}\n"
f"user-invocable: {front['user-invocable']}\n"
f"disable-model-invocation: {front['disable-model-invocation']}\n"
f"metadata: {json.dumps(front['metadata'], ensure_ascii=False)}\n"
"---\n\n"
f"{str(body_markdown or '').strip()}\n"
)
(target / "SKILL.md").write_text(text, encoding="utf-8")
set_skill_enabled(store=store, skill_name=nm, enabled=True)
ec, rt = _classify_install_detail("created")
return SkillInstallResult(ok=True, name=nm, target_dir=str(target), detail="created", error_code=ec, retryable=rt)
def create_workspace_skill(
*,
store: Any,
name: str,
description: str,
runtime_type: str = "python",
skills_root: str | Path | None = None,
overwrite: bool = False,
) -> SkillInstallResult:
nm = str(name or "").strip()
if not nm:
ec, rt = _classify_install_detail("name_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
rt_type = str(runtime_type or "python").strip().lower()
if rt_type not in {"python", "shell", "node"}:
ec, rt = _classify_install_detail("unsupported_runtime_type")
return SkillInstallResult(ok=False, name=nm, target_dir="", detail="unsupported_runtime_type", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
target = root / "_workspace" / nm
if target.exists() and not overwrite:
ec, rt = _classify_install_detail("already_exists")
return SkillInstallResult(ok=False, name=nm, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
target.mkdir(parents=True, exist_ok=True)
scripts_dir = target / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
entry = "scripts/run.py" if rt_type == "python" else ("scripts/run.sh" if rt_type == "shell" else "scripts/run.js")
script_path = target / entry
if rt_type == "python":
script_path.write_text(
"import json\nimport sys\n\n"
"def main():\n"
" data = json.loads(sys.stdin.read() or '{}')\n"
" args = data.get('args') or {}\n"
" print(json.dumps({'ok': True, 'echo': args}, ensure_ascii=False))\n\n"
"if __name__ == '__main__':\n"
" main()\n",
encoding="utf-8",
)
elif rt_type == "shell":
script_path.write_text("#!/usr/bin/env bash\ncat\n", encoding="utf-8")
else:
script_path.write_text("const fs=require('fs'); const input=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); console.log(JSON.stringify({ok:true,echo:input.args||{}}));\n", encoding="utf-8")
metadata_oclaw = {
"source": {"kind": "workspace", "provider": "local", "version": "workspace"},
"runtime": {
"type": rt_type,
"entry": entry,
"schema": {"type": "object", "additionalProperties": True},
"permissions": {"fs_write": False, "net": False, "process": True},
},
}
out = create_skill_from_template(
store=store,
name=nm,
description=description,
body_markdown="Workspace authored skill.",
metadata_oclaw=metadata_oclaw,
skills_root=target.parent,
overwrite=True,
)
if not out.ok:
return out
auto_enabled, roles = _apply_auto_enable_binding(store=store, skill_name=nm, skills_root=root)
return SkillInstallResult(
ok=out.ok,
name=out.name,
target_dir=out.target_dir,
detail=out.detail,
error_code=out.error_code,
retryable=out.retryable,
auto_enabled=auto_enabled,
binding_applied_roles=roles,
)
def auto_install_skill_from_payload(
*,
store: Any,
payload: dict[str, Any],
skills_root: str | Path | None = None,
) -> SkillInstallResult:
if not skill_auto_install_enabled(store):
ec, rt = _classify_install_detail("auto_install_disabled")
return SkillInstallResult(ok=False, name="", target_dir="", detail="auto_install_disabled", error_code=ec, retryable=rt)
name = str(payload.get("name") or "").strip()
description = str(payload.get("description") or "").strip()
body = str(payload.get("body_markdown") or "").strip()
md = payload.get("metadata_oclaw")
md = dict(md) if isinstance(md, dict) else {}
root = Path(skills_root).resolve() if skills_root else default_skills_root()
# Keep agent-authored/auto-installed skills isolated from primary managed skills.
workspace_root = root / "_workspace"
target = workspace_root / name
before_disabled = _get_disabled_names(store)
try:
out = create_skill_from_template(
store=store,
name=name,
description=description,
body_markdown=body,
metadata_oclaw=md,
skills_root=workspace_root,
overwrite=False,
)
if bool(payload.get("force_error_for_test")):
raise RuntimeError("forced_error_for_test")
if not out.ok:
return out
auto_enabled, roles = _apply_auto_enable_binding(store=store, skill_name=name, skills_root=root)
return SkillInstallResult(
ok=out.ok,
name=out.name,
target_dir=out.target_dir,
detail=out.detail,
error_code=out.error_code,
retryable=out.retryable,
auto_enabled=auto_enabled,
binding_applied_roles=roles,
)
except Exception as exc:
try:
if target.exists() and target.is_dir():
shutil.rmtree(target)
except Exception:
pass
try:
_set_disabled_names(store, before_disabled)
except Exception:
pass
detail = f"rollback_after_error:{type(exc).__name__}"
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail=detail, error_code=ec, retryable=rt)
__all__ = [
"SkillInstallResult",
"auto_install_skill_from_payload",
"create_skill_from_template",
"create_workspace_skill",
"install_skill_from_local_dir",
"install_skill_from_registry_archive",
"list_skills_with_status",
"set_skill_enabled",
"skill_auto_install_enabled",
"uninstall_skill",
]