统一技能安装与 Skills 市场接入链路。

新增公开安装工具与多来源 provider 支持,补齐管理端与目录加载逻辑,并同步更新相关测试与文档以保证可见性和路径安全。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-30 22:17:50 +08:00
parent d5e30542aa
commit 4d9232f3b3
66 changed files with 7369 additions and 1099 deletions

View file

@ -140,9 +140,11 @@ class AdminSkillsApiTests(unittest.TestCase):
self.assertEqual(g.status_code, 200, g.text)
gb = g.json() or {}
self.assertTrue(gb.get("ok"))
self.assertIn("market_provider", gb)
self.assertIn(str(gb.get("market_provider") or ""), {"clawhub", "cocoloop"})
s = self.client.post(
"/admin/api/skills/mode",
json={"prompt_in_system": True, "toolcall_enabled": False},
json={"prompt_in_system": True, "toolcall_enabled": False, "market_provider": "cocoloop"},
headers=self._h(),
)
self.assertEqual(s.status_code, 200, s.text)
@ -150,6 +152,9 @@ class AdminSkillsApiTests(unittest.TestCase):
self.assertTrue(sb.get("ok"))
self.assertTrue(bool(sb.get("prompt_in_system")))
self.assertFalse(bool(sb.get("toolcall_enabled")))
self.assertEqual(str(sb.get("market_provider") or ""), "cocoloop")
g2 = self.client.get("/admin/api/skills/mode", headers=self._h())
self.assertEqual((g2.json() or {}).get("market_provider"), "cocoloop")
def test_skills_effective_dashboard(self) -> None:
c = self.client.post(

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path
import zipfile
import subprocess
from oclaw.runtime.skill_installer import (
auto_install_skill_from_payload,
@ -9,6 +10,7 @@ from oclaw.runtime.skill_installer import (
install_skill_from_local_dir,
install_skill_from_registry_archive,
list_skills_with_status,
repair_skill_dependencies,
set_skill_enabled,
)
from oclaw.platform.persistence.sqlite_store import SqliteStore
@ -102,6 +104,33 @@ def test_install_skill_from_registry_archive_file_url(tmp_path: Path) -> None:
assert out.name == "reg_demo"
def test_install_skill_from_registry_archive_workspace_auto_bind(tmp_path: Path) -> None:
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
pkg_dir = tmp_path / "pkg_ws"
inner = pkg_dir / "demo"
inner.mkdir(parents=True, exist_ok=True)
(inner / "SKILL.md").write_text(
"---\nname: reg_ws_demo\ndescription: x\nmetadata: {\"oclaw\":{}}\n---\n",
encoding="utf-8",
)
archive = tmp_path / "reg_ws.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.write(inner / "SKILL.md", arcname="demo/SKILL.md")
root = tmp_path / "skills"
out = install_skill_from_registry_archive(
store=store,
archive_url=archive.resolve().as_uri(),
skills_root=root / "_workspace",
auto_bind=True,
)
assert out.ok
assert out.name == "reg_ws_demo"
assert out.auto_enabled is True
assert len(out.binding_applied_roles) >= 1
assert (root / "_workspace" / "reg_ws_demo" / "SKILL.md").exists()
def test_install_skill_from_clawhub_page_url(tmp_path: Path, monkeypatch) -> None:
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
@ -149,3 +178,103 @@ def test_install_local_allows_sh_files(tmp_path: Path) -> None:
assert out.ok
assert out.name == "local_with_sh"
def test_install_local_auto_installs_python_requirements(tmp_path: Path, monkeypatch) -> None:
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
src = tmp_path / "skill_with_reqs"
src.mkdir(parents=True, exist_ok=True)
(src / "SKILL.md").write_text(
"---\nname: with_reqs\ndescription: x\nmetadata: {\"oclaw\":{}}\n---\n",
encoding="utf-8",
)
(src / "requirements.txt").write_text("requests>=2.0.0\n", encoding="utf-8")
calls: list[list[str]] = []
def _mock_run(cmd, **kwargs): # noqa: ANN001
calls.append([str(x) for x in cmd])
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr("oclaw.runtime.skill_installer.subprocess.run", _mock_run)
out = install_skill_from_local_dir(store=store, source_dir=src, skills_root=tmp_path / "skills")
assert out.ok
assert any(("pip" in " ".join(c) and "-r" in c) for c in calls)
def test_install_local_dependency_install_failure_returns_warning(tmp_path: Path, monkeypatch) -> None:
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
src = tmp_path / "skill_with_bad_reqs"
src.mkdir(parents=True, exist_ok=True)
(src / "SKILL.md").write_text(
"---\nname: with_bad_reqs\ndescription: x\nmetadata: {\"oclaw\":{}}\n---\n",
encoding="utf-8",
)
(src / "requirements.txt").write_text("not_a_real_pkg_zzz\n", encoding="utf-8")
def _mock_run(cmd, **kwargs): # noqa: ANN001,ARG001
return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="install failed")
monkeypatch.setattr("oclaw.runtime.skill_installer.subprocess.run", _mock_run)
out = install_skill_from_local_dir(store=store, source_dir=src, skills_root=tmp_path / "skills")
assert out.ok
assert out.detail.startswith("installed_with_dependency_warnings:")
def test_install_local_probe_missing_imports_and_install(tmp_path: Path, monkeypatch) -> None:
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
src = tmp_path / "skill_probe_imports"
src.mkdir(parents=True, exist_ok=True)
(src / "SKILL.md").write_text(
"---\nname: probe_imports\ndescription: x\nmetadata: {\"oclaw\":{}}\n---\n",
encoding="utf-8",
)
(src / "main.py").write_text(
"import json\nimport office\nimport pandas\nimport totally_missing_pkg_xyz\n",
encoding="utf-8",
)
office_dir = src / "office"
office_dir.mkdir(parents=True, exist_ok=True)
(office_dir / "__init__.py").write_text("", encoding="utf-8")
calls: list[list[str]] = []
def _mock_run(cmd, **kwargs): # noqa: ANN001,ARG001
calls.append([str(x) for x in cmd])
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr("oclaw.runtime.skill_installer.subprocess.run", _mock_run)
out = install_skill_from_local_dir(store=store, source_dir=src, skills_root=tmp_path / "skills")
assert out.ok
pip_calls = [c for c in calls if ("pip" in " ".join(c))]
assert pip_calls
assert any("totally_missing_pkg_xyz" in c for c in pip_calls)
def test_repair_skill_dependencies_for_installed_skill(tmp_path: Path, monkeypatch) -> None:
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
src = tmp_path / "skill_repair"
src.mkdir(parents=True, exist_ok=True)
(src / "SKILL.md").write_text(
"---\nname: skill_repair\ndescription: x\nmetadata: {\"oclaw\":{}}\n---\n",
encoding="utf-8",
)
(src / "main.py").write_text("import definitely_missing_pkg_abc\n", encoding="utf-8")
calls: list[list[str]] = []
def _mock_run(cmd, **kwargs): # noqa: ANN001,ARG001
calls.append([str(x) for x in cmd])
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr("oclaw.runtime.skill_installer.subprocess.run", _mock_run)
out = install_skill_from_local_dir(store=store, source_dir=src, skills_root=tmp_path / "skills")
assert out.ok
calls.clear()
result = repair_skill_dependencies(store=store, skill_name="skill_repair", skills_root=tmp_path / "skills")
assert bool(result.get("ok")) is True
assert any("definitely_missing_pkg_abc" in c for c in calls)

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from oclaw.runtime.tools.catalog import default_registry
def test_skill_install_public_tools_hidden_for_specialist_auto_only() -> None:
names = [t.name for t in default_registry(expert="network_ops+memory", specialist="ops").list()]
assert "skill_market_install" not in names
assert "skill_registry_install" not in names

View file

@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from oclaw.runtime.tools.public.skills_install_tool import skill_market_install_tool, skill_registry_install_tool
def test_skill_registry_install_tool_forces_workspace_root(monkeypatch, tmp_path: Path) -> None:
captured: dict[str, str] = {}
def _mock_default_root() -> Path:
return tmp_path / "skills"
def _mock_install(**kwargs): # noqa: ANN003
captured["skills_root"] = str(kwargs.get("skills_root") or "")
class _Out:
ok = True
name = "demo"
target_dir = str((tmp_path / "skills" / "_workspace" / "demo").resolve())
detail = "installed"
error_code = "ok"
retryable = False
return _Out()
monkeypatch.setattr("oclaw.runtime.tools.public.skills_install_tool.default_skills_root", _mock_default_root)
monkeypatch.setattr("oclaw.runtime.tools.public.skills_install_tool.install_skill_from_registry_archive", _mock_install)
tool = skill_registry_install_tool()
result = tool.handler({"archive_url": "https://example.com/demo.zip"})
assert bool(result.get("ok")) is True
assert captured["skills_root"].replace("\\", "/").endswith("/skills/_workspace")
def test_skill_market_install_tool_provider_arg_overrides_setting(monkeypatch, tmp_path: Path) -> None:
captured: dict[str, str] = {}
class _FakeStore:
def get_setting(self, key: str) -> str:
if key == "AIA_SKILL_MARKET_PROVIDER":
return "clawhub"
return ""
class _FakeAdapter:
def resolve_archive_url(self, *, slug: str, version: str | None = None) -> tuple[str, str]:
captured["slug"] = slug
captured["version"] = str(version or "")
return "https://example.com/demo.zip", "1.0.0"
def _mock_store() -> _FakeStore:
return _FakeStore()
def _mock_default_root() -> Path:
return tmp_path / "skills"
def _mock_get_market_adapter(provider: str): # noqa: ANN001
captured["provider"] = provider
return _FakeAdapter()
def _mock_install(**kwargs): # noqa: ANN003
captured["skills_root"] = str(kwargs.get("skills_root") or "")
class _Out:
ok = True
name = "demo"
target_dir = str((tmp_path / "skills" / "_workspace" / "demo").resolve())
detail = "installed"
error_code = "ok"
retryable = False
return _Out()
monkeypatch.setattr("oclaw.runtime.tools.public.skills_install_tool._store", _mock_store)
monkeypatch.setattr("oclaw.runtime.tools.public.skills_install_tool.default_skills_root", _mock_default_root)
monkeypatch.setattr("oclaw.runtime.tools.public.skills_install_tool.get_market_adapter", _mock_get_market_adapter)
monkeypatch.setattr("oclaw.runtime.tools.public.skills_install_tool.install_skill_from_registry_archive", _mock_install)
tool = skill_market_install_tool()
result = tool.handler({"slug": "demo", "provider": "cocoloop", "version": "latest"})
assert bool(result.get("ok")) is True
assert captured["provider"] == "cocoloop"
assert captured["skills_root"].replace("\\", "/").endswith("/skills/_workspace")

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from oclaw.runtime import skills_market
def test_get_market_adapter_clawhub_default() -> None:
a = skills_market.get_market_adapter("clawhub")
assert a.provider == "clawhub"
def test_get_market_adapter_cocoloop() -> None:
a = skills_market.get_market_adapter("cocoloop")
assert a.provider == "cocoloop"
def test_get_market_adapter_cocoloop_alias() -> None:
a = skills_market.get_market_adapter("cocoloop-cn")
assert a.provider == "cocoloop"
def test_cocoloop_resolve_archive_url(monkeypatch) -> None:
def _fake_detail(slug: str) -> dict: # noqa: ANN001
return {
"source": "cocoloop",
"slug": slug,
"latestVersion": "1.0.0",
"archiveUrl": "https://dl.example/bss/skills/demo.zip",
"versions": [{"version": "1.0.0", "archiveUrl": "https://dl.example/bss/skills/demo.zip"}],
}
monkeypatch.setattr("oclaw.runtime.skills_market.cocoloop_get_skill_detail", _fake_detail)
a = skills_market.CocoloopMarketAdapter()
url, ver = a.resolve_archive_url(slug="demo", version=None)
assert url.endswith("demo.zip")
assert ver == "1.0.0"

View file

@ -41,11 +41,50 @@ def test_tool_loop_guard_blocks_repeated_signature(tmp_path: Path) -> None:
tool_uses=tool_uses,
signature_budget=2,
)
assert calls["n"] == 2
# Same-round duplicate calls now hit cache; only the first executes.
assert calls["n"] == 1
second, _ = results["c2"]
assert bool(second.get("ok")) is True
blocked, _ = results["c3"]
assert blocked.get("error_code") == "tool_loop_guard"
def test_same_round_duplicate_tool_call_reuses_cached_result(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "dup.sqlite"))
sess = store.create_session("t")
calls = {"n": 0}
def _handler(args):
calls["n"] += 1
return {"ok": True, "echo": args, "counter": calls["n"]}
reg = ToolRegistry(
[
ToolSpec(
name="echo",
description="echo",
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [
LLMToolCall(id="c1", name="echo", arguments={"x": 1}),
LLMToolCall(id="c2", name="echo", arguments={"x": 1}),
]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
signature_budget=2,
)
assert calls["n"] == 1
r1, _ = results["c1"]
r2, _ = results["c2"]
assert r1 == r2
def test_repeated_tool_results_are_compacted_in_history(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g2.sqlite"))
sess = store.create_session("t")

View file

@ -256,6 +256,43 @@ class WorkspacePathGuardTests(unittest.TestCase):
self.assertEqual(str(r.get("error_code") or ""), "command_exit_nonzero")
self.assertFalse(bool(r.get("output_truncated")))
def test_run_command_blocks_cocoloop_install_cli(self) -> None:
with mock.patch.dict(
os.environ,
{
"OPS_WORKSPACE_ROOT": str(self.root),
"OPS_WORKSPACE_EXTRA_ROOTS": "",
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
"AIA_ENABLE_RUN_COMMAND": "1",
},
clear=False,
):
clear_workspace_path_access_for_tests()
spec = run_command_tool()
with workspace_path_access_scope(None, None):
r = spec.handler({"command": "cocoloop install 7288"})
self.assertFalse(bool(r.get("ok")), r)
self.assertEqual("skill_install_cli_blocked", str(r.get("error_code") or ""))
self.assertIn("market/install", str(r.get("hint") or ""))
def test_run_command_blocks_npx_clawhub_install_pattern(self) -> None:
with mock.patch.dict(
os.environ,
{
"OPS_WORKSPACE_ROOT": str(self.root),
"OPS_WORKSPACE_EXTRA_ROOTS": "",
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
"AIA_ENABLE_RUN_COMMAND": "1",
},
clear=False,
):
clear_workspace_path_access_for_tests()
spec = run_command_tool()
with workspace_path_access_scope(None, None):
r = spec.handler({"command": "npx -y clawhub@latest install foo"})
self.assertFalse(bool(r.get("ok")), r)
self.assertEqual("skill_install_cli_blocked", str(r.get("error_code") or ""))
def test_run_command_rewrites_workspace_absolute_script_path_to_sandbox(self) -> None:
with mock.patch.dict(
os.environ,