mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
修复 Admin Skills 安装区块不显示,并支持卸载 _workspace/public lane。
同时补齐 _workspace/public 目录约定、公共技能免绑定规则,并为 run_command 增加默认禁用开关与回归测试。 Made-with: Cursor
This commit is contained in:
parent
da2413afad
commit
5d67303d22
11 changed files with 118 additions and 5 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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__"):
|
||||
|
|
|
|||
|
|
@ -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` 下。
|
||||
|
|
|
|||
16
runtime/skills/_workspace/public/README.md
Normal file
16
runtime/skills/_workspace/public/README.md
Normal 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`、租户禁用列表等策略影响。
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue