diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index ba9adcc9..1e3d3329 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -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") { diff --git a/runtime/skill_installer.py b/runtime/skill_installer.py index 8a1fd16e..92e33332 100644 --- a/runtime/skill_installer.py +++ b/runtime/skill_installer.py @@ -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: diff --git a/runtime/skill_role_binding.py b/runtime/skill_role_binding.py index ff73a45b..09be0e1c 100644 --- a/runtime/skill_role_binding.py +++ b/runtime/skill_role_binding.py @@ -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]: diff --git a/runtime/skills.py b/runtime/skills.py index fee9a8c0..dbe7936e 100644 --- a/runtime/skills.py +++ b/runtime/skills.py @@ -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 `/_workspace/public//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__"): diff --git a/runtime/skills/README.md b/runtime/skills/README.md index 39817c6e..4c10db48 100644 --- a/runtime/skills/README.md +++ b/runtime/skills/README.md @@ -15,6 +15,8 @@ 用于官方/手工管理的稳定技能(安装、维护、评审都在这层)。 - **自写目录**:`oclaw/runtime/skills/_workspace//` 用于 agent 自写/自动安装技能,和主目录隔离,避免混放。 +- **公共目录**:`oclaw/runtime/skills/_workspace/public//` + 放在这里的 skill 默认对所有人可用,不需要做角色绑定(适合通用能力)。 说明: - `auto_install_skill_from_payload` 产物默认落在 `_workspace` 下。 diff --git a/runtime/skills/_workspace/public/README.md b/runtime/skills/_workspace/public/README.md new file mode 100644 index 00000000..4e7a85e2 --- /dev/null +++ b/runtime/skills/_workspace/public/README.md @@ -0,0 +1,16 @@ +本目录用于 **公共 skills**。 + +约定: +- 路径:`oclaw/runtime/skills/_workspace/public//SKILL.md` +- 放在这里的 skill **默认对所有人可用**,不需要在 Admin 里做角色绑定。 + +注意: +- 仍会受 skill 本身的 `disable_model_invocation`、租户禁用列表等策略影响。 +本目录用于 **公共 skills**。 + +约定: +- 路径:`oclaw/runtime/skills/_workspace/public//SKILL.md` +- 放在这里的 skill **默认对所有人可用**,不需要在 Admin 里做角色绑定。 + +注意: +- 仍会受 skill 本身的 `disable_model_invocation`、租户禁用列表等策略影响。 diff --git a/runtime/tools/local_sdk/adapter.py b/runtime/tools/local_sdk/adapter.py index d278b383..83c4b2a0 100644 --- a/runtime/tools/local_sdk/adapter.py +++ b/runtime/tools/local_sdk/adapter.py @@ -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. diff --git a/tests/test_admin_skills_api.py b/tests/test_admin_skills_api.py index 80578207..f3a50ee5 100644 --- a/tests/test_admin_skills_api.py +++ b/tests/test_admin_skills_api.py @@ -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", diff --git a/tests/test_local_public_tools.py b/tests/test_local_public_tools.py index 54794c5c..0a945771 100644 --- a/tests/test_local_public_tools.py +++ b/tests/test_local_public_tools.py @@ -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( diff --git a/tests/test_oclaw_generalist_executor.py b/tests/test_oclaw_generalist_executor.py index 90fb2aeb..6c497828 100644 --- a/tests/test_oclaw_generalist_executor.py +++ b/tests/test_oclaw_generalist_executor.py @@ -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) diff --git a/tests/test_skill_role_binding_catalog.py b/tests/test_skill_role_binding_catalog.py index 7469722b..18d84f3c 100644 --- a/tests/test_skill_role_binding_catalog.py +++ b/tests/test_skill_role_binding_catalog.py @@ -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: