修复 Admin Skills 安装区块不显示,并支持卸载 _workspace/public lane。

同时补齐 _workspace/public 目录约定、公共技能免绑定规则,并为 run_command 增加默认禁用开关与回归测试。

Made-with: Cursor
This commit is contained in:
oliver 2026-05-01 03:34:32 +08:00
parent da2413afad
commit 5d67303d22
11 changed files with 118 additions and 5 deletions

View file

@ -355,11 +355,32 @@ def uninstall_skill(
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)
# Prefer manifest directory resolution to handle workspace lanes and public lane.
target: Path | None = None
try:
manifests = list(discover_workspace_skill_manifests(root))
m = next((x for x in manifests if str(x.name or "").strip() == name), None)
if m and str(m.skill_dir or "").strip():
target = Path(str(m.skill_dir)).resolve()
except Exception:
target = None
if target is None:
# Fallback to legacy lane guesses.
candidates = [root / name, root / "_workspace" / name, root / "_workspace" / "public" / 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)
# Safety: only allow uninstall within the configured skills root.
try:
root_r = root.resolve()
ws_r = (root_r / "_workspace").resolve()
if not (str(target).startswith(str(root_r)) or str(target).startswith(str(ws_r))):
ec, rt = _classify_install_detail("runtime_error")
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail="remove_failed:path_restricted", error_code=ec, retryable=rt)
except Exception:
ec, rt = _classify_install_detail("runtime_error")
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail="remove_failed:path_restricted", error_code=ec, retryable=rt)
try:
shutil.rmtree(target)
except Exception as exc:

View file

@ -86,6 +86,9 @@ def allowed_workspace_skill_names_for_role(*, store: Any, role: str) -> set[str]
r = str(role or "").strip().lower()
if not r:
return set()
from oclaw.runtime.skills import discover_public_workspace_skill_names
public = discover_public_workspace_skill_names()
mapping = normalize_skill_role_binding(
mapping_raw=load_skill_role_binding_dict(store),
valid_skill_names=_all_installed_skill_names(store),
@ -101,7 +104,7 @@ def allowed_workspace_skill_names_for_role(*, store: Any, role: str) -> set[str]
inherit_mgr = True
mgr = {str(x).strip() for x in (mapping.get("manager") or []) if str(x).strip()} if inherit_mgr else set()
sp = {str(x).strip() for x in (mapping.get(r) or []) if str(x).strip()}
return mgr | sp
return mgr | sp | public
def _all_installed_skill_names(store: Any) -> set[str]:

View file

@ -148,6 +148,28 @@ def discover_workspace_skill_manifests(skills_root: str | Path | None = None) ->
return tuple(out)
def discover_public_workspace_skill_names(skills_root: str | Path | None = None) -> set[str]:
"""Skill names under `<skills_root>/_workspace/public/<skill>/SKILL.md`.
These skills are treated as public and do not require role binding.
"""
base = Path(skills_root).resolve() if skills_root else default_skills_root()
public_root = (base / "_workspace" / "public").resolve()
if not public_root.exists() or not public_root.is_dir():
return set()
out: set[str] = set()
try:
for md in public_root.rglob("SKILL.md"):
if not md.is_file():
continue
m = load_skill_manifest(md.parent)
if m and str(m.name or "").strip():
out.add(str(m.name).strip())
except Exception:
return set()
return out
def _tool_origin(tool: "ToolSpec") -> str:
nm = str(getattr(tool, "name", "") or "")
if nm.startswith("mcp__"):

View file

@ -15,6 +15,8 @@
用于官方/手工管理的稳定技能(安装、维护、评审都在这层)。
- **自写目录**:`oclaw/runtime/skills/_workspace/<skill_name>/`
用于 agent 自写/自动安装技能,和主目录隔离,避免混放。
- **公共目录**:`oclaw/runtime/skills/_workspace/public/<skill_name>/`
放在这里的 skill 默认对所有人可用,不需要做角色绑定(适合通用能力)。
说明:
- `auto_install_skill_from_payload` 产物默认落在 `_workspace` 下。

View file

@ -0,0 +1,16 @@
本目录用于 **公共 skills**。
约定:
- 路径:`oclaw/runtime/skills/_workspace/public/<skill_name>/SKILL.md`
- 放在这里的 skill **默认对所有人可用**,不需要在 Admin 里做角色绑定。
注意:
- 仍会受 skill 本身的 `disable_model_invocation`、租户禁用列表等策略影响。
本目录用于 **公共 skills**。
约定:
- 路径:`oclaw/runtime/skills/_workspace/public/<skill_name>/SKILL.md`
- 放在这里的 skill **默认对所有人可用**,不需要在 Admin 里做角色绑定。
注意:
- 仍会受 skill 本身的 `disable_model_invocation`、租户禁用列表等策略影响。

View file

@ -70,6 +70,31 @@ class LocalAdapter:
cmd = str(command or "").strip()
if not cmd:
return {"ok": False, "error_code": "command_required", "error": "command_required"}
# Safety gate: disabled by default unless explicitly enabled.
try:
def _truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
enabled: bool | None = None
# Prefer DB setting when available.
dbp = str(os.getenv("OPS_ASSISTANT_DB_PATH") or "").strip()
if dbp:
try:
from oclaw.platform.persistence.sqlite_store import SqliteStore
store = SqliteStore(dbp)
raw_db = str(store.get_setting("AIA_ENABLE_RUN_COMMAND") or "").strip()
if raw_db:
enabled = _truthy(raw_db)
except Exception:
enabled = None
if enabled is None:
raw_env = str(os.getenv("AIA_ENABLE_RUN_COMMAND") or "").strip()
enabled = _truthy(raw_env) if raw_env else False
if not enabled:
return {"ok": False, "error_code": "disabled", "error": "disabled"}
except Exception:
return {"ok": False, "error_code": "disabled", "error": "disabled"}
try:
timeout_s = max(1, min(int(timeout or 30), 600))
# run_command never follows adapter cd state.