修复 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

@ -8743,6 +8743,16 @@ async function renderSkills() {
]),
docsHint,
marketBox,
el("details", { style: "margin:10px 0 14px 0;" }, [
el("summary", { text: "Skill installation (local / registry)", style: "cursor:pointer;user-select:none;" }),
el("div", { style: "height:8px" }),
el("div", { class: "muted", style: "margin-bottom:8px;line-height:1.5;" }, [
el("div", { text: "Local dir: 目录内必须包含 SKILL.md(支持绝对路径)。" }),
el("div", { text: "Registry: zip/tar 的 URL(支持 file:// 本地 URI)。" }),
]),
el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [regInp, btnInstallRegistry]),
el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [localDirInp, btnInstallLocal]),
]),
skillBindingBox,
selfCheckBox,
skillHealthBox,
@ -8812,7 +8822,7 @@ async function router() {
else if (p === "session-monitor" && !isAdministratorUsername()) a.style.display = "none";
else if (p === "admin-audit" && !hasPermission("admin:user:write")) a.style.display = "none";
else if (p === "plugins" && !hasPermission("admin:user:write")) a.style.display = "none";
else if (p === "skills" && !hasPermission("admin:user:write")) a.style.display = "none";
else if (p === "skills" && !hasPermission("admin:read")) a.style.display = "none";
else if (p === "attachments" && !isAdministratorUsername()) a.style.display = "none";
else if (p === "workspace-paths" && !hasPermission("admin:user:read") && !hasPermission("admin:workspace_paths:read")) a.style.display = "none";
else if (p === "api-grants" && !canManageApiGrants()) a.style.display = "none";
@ -8855,7 +8865,7 @@ async function router() {
);
view = hasPermission("admin:user:write") ? await renderPlugins() : forbiddenCard();
} else if (page === "skills") {
view = hasPermission("admin:user:write") ? await renderSkills() : forbiddenCard();
view = hasPermission("admin:read") ? await renderSkills() : forbiddenCard();
} else if (page === "attachments") {
view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard();
} else if (page === "workspace-paths") {

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.

View file

@ -240,6 +240,11 @@ class AdminSkillsApiTests(unittest.TestCase):
self.assertFalse(bool(result2.get("ok")))
self.assertIn(str(result2.get("error_code") or ""), {"path_restricted", "runtime_error"})
# Uninstall should be able to remove workspace lane skills.
u = self.client.post("/admin/api/skills/uninstall", json={"name": "ws_demo_skill"}, headers=self._h())
self.assertEqual(u.status_code, 200, u.text)
self.assertTrue((u.json() or {}).get("ok"))
def test_skills_self_check_endpoint(self) -> None:
c = self.client.post(
"/admin/api/skills/create-workspace",

View file

@ -61,6 +61,7 @@ def test_local_public_high_risk_tools_visible_when_enabled(monkeypatch) -> None:
def test_local_adapter_backend_roundtrip(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AIA_ENABLE_RUN_COMMAND", "1")
adapter = LocalAdapter()
out_w = adapter.write_file(path="a.txt", content="hello\nworld\n")
assert out_w.get("ok") is True
@ -124,6 +125,7 @@ def test_local_tool_integration_roundtrip(monkeypatch) -> None:
tmpdir = Path(tempfile.mkdtemp(prefix="local_it_"))
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmpdir))
monkeypatch.setenv("AIA_ENABLE_RUN_COMMAND", "1")
target_rel = "data/workspace/it_sample.txt"
out_write = write_spec.handler({"path": "it_sample.txt", "content": "line1\nline2\n", "mode": "overwrite"})
@ -178,6 +180,7 @@ def test_p1_p2_read_tools_smoke(tmp_path: Path, monkeypatch) -> None:
def test_run_command_does_not_follow_cd_state(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AIA_ENABLE_RUN_COMMAND", "1")
(tmp_path / "data" / "workspace").mkdir(parents=True, exist_ok=True)
(tmp_path / "subdir").mkdir(parents=True, exist_ok=True)
(tmp_path / "subdir" / "echo_dir.py").write_text(

View file

@ -17,6 +17,7 @@ def test_build_gateway_executor_defaults_to_generalist(tmp_path: Path) -> None:
def test_build_gateway_executor_generalist_run_command_is_disabled_by_default(tmp_path: Path) -> None:
os.environ.pop("AIA_ENABLE_RUN_COMMAND", None)
os.environ["AIA_PUBLIC_TOOLS_ALLOW_HIGH"] = "1"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(tmp_path / "ops.sqlite")
try:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
@ -28,11 +29,13 @@ def test_build_gateway_executor_generalist_run_command_is_disabled_by_default(tm
assert out.get("ok") is False
assert out.get("error") == "disabled"
finally:
os.environ.pop("AIA_PUBLIC_TOOLS_ALLOW_HIGH", None)
os.environ.pop("OPS_ASSISTANT_DB_PATH", None)
def test_build_gateway_executor_generalist_run_command_prefers_db_setting(tmp_path: Path) -> None:
os.environ["AIA_ENABLE_RUN_COMMAND"] = "0"
os.environ["AIA_PUBLIC_TOOLS_ALLOW_HIGH"] = "1"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(tmp_path / "ops.sqlite")
try:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
@ -45,5 +48,6 @@ def test_build_gateway_executor_generalist_run_command_prefers_db_setting(tmp_pa
assert out.get("ok") is True
finally:
os.environ.pop("AIA_ENABLE_RUN_COMMAND", None)
os.environ.pop("AIA_PUBLIC_TOOLS_ALLOW_HIGH", None)
os.environ.pop("OPS_ASSISTANT_DB_PATH", None)

View file

@ -30,6 +30,7 @@ def test_collect_respects_role_binding_union(tmp_path: Path, monkeypatch) -> Non
skills_root.mkdir(parents=True, exist_ok=True)
_write_skill(skills_root, "skill-alpha")
_write_skill(skills_root, "skill-beta")
_write_skill(skills_root / "_workspace" / "public", "skill-public")
monkeypatch.setenv("AIA_SKILLS_ROOT", str(skills_root))
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1")
@ -48,6 +49,7 @@ def test_collect_respects_role_binding_union(tmp_path: Path, monkeypatch) -> Non
names = {e[0] for e in entries}
assert "skill-alpha" in names
assert "skill-beta" in names
assert "skill-public" in names
def test_collect_unfiltered_when_binding_disabled(tmp_path: Path, monkeypatch) -> None: