mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
统一技能安装与 Skills 市场接入链路。
新增公开安装工具与多来源 provider 支持,补齐管理端与目录加载逻辑,并同步更新相关测试与文档以保证可见性和路径安全。 Made-with: Cursor
This commit is contained in:
parent
d5e30542aa
commit
4d9232f3b3
66 changed files with 7369 additions and 1099 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
10
tests/test_skills_install_public_tool_visibility.py
Normal file
10
tests/test_skills_install_public_tool_visibility.py
Normal 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
|
||||
|
||||
83
tests/test_skills_install_tool_workspace_root.py
Normal file
83
tests/test_skills_install_tool_workspace_root.py
Normal 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")
|
||||
|
||||
35
tests/test_skills_market_providers.py
Normal file
35
tests/test_skills_market_providers.py
Normal 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"
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue