mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-17 05:49:32 +08:00
* feat: bridge plugin-contributed channel kinds (infra + API) Octop-harness plugins can now contribute channel kinds (kind: channel) that become first-class channels in the gateway and dashboard. - infra/gateway/plugin_channels.py: apply_plugin_channels() replays plugin channel registrations into octop_gateway.register_channel_kind (idempotent, first-wins, per-plugin failure isolation); plugin_channel_kinds() summarizes them for the dashboard catalogue. - server.py: apply plugin channel kinds right after load_installed() so kinds exist before gateway boot rebuilds DB channels. - api/routers/channels.py: kind validation now accepts plugin kinds (case-normalized) in create/patch/probe bodies, tolerant of harness releases without all_channels(); new GET /channels/plugin-kinds endpoint exposing the plugin channel catalogue. Tests: 8 cases in tests/unit/test_plugin_channels.py (7 run on released harness; the fixture end-to-end test auto-skips until the updated harness is installed). * feat(dashboard): merge plugin-contributed channel kinds into the catalogue Fetch GET /channels/plugin-kinds once per mount and register each returned kind into the channel catalogue at runtime (keys, labels, icons, colors, per-kind config schema). Builtin kinds stay compile-time constants; registration is idempotent and builtin kinds always win. Kinds registered without a schema fall back to the drawer's existing raw-JSON editing path, so any kind:channel plugin is configurable without further frontend work. Fetch failure keeps a builtin-only catalogue (older backends without the endpoint). * docs: add plugin channel support entry to CHANGELOG * fix(channels): expose registration errors and reload only changed kinds * fix(plugins): support released harness and gateway in CI * fix(channels): isolate persistent registration failures by kind * fix: retry failed plugin channel removals without blocking refresh --------- Co-authored-by: Chovy <chovy@local> Co-authored-by: ideshun <28327099+ideshun@users.noreply.github.com> Co-authored-by: 薄生 <67680641+Bosheng0422@users.noreply.github.com>
430 lines
16 KiB
Python
430 lines
16 KiB
Python
"""Unit tests for Octop plugin manager."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from octop_harness.plugins import PluginRegistry
|
|
|
|
from octop.infra.agents.plugins.manager import (
|
|
PluginManager,
|
|
normalize_plugin_download_url,
|
|
parse_plugin_group,
|
|
parse_plugin_icon,
|
|
parse_plugin_ui_meta,
|
|
)
|
|
from octop.infra.errors import ErrorCode, OctopError
|
|
|
|
_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "plugins" / "echo-tool"
|
|
|
|
|
|
def _echo_zip_bytes() -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
for path in _FIXTURE.rglob("*"):
|
|
if path.is_file():
|
|
zf.write(path, arcname=f"echo-tool/{path.relative_to(_FIXTURE).as_posix()}")
|
|
return buf.getvalue()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_registry() -> None:
|
|
PluginRegistry.reset()
|
|
yield
|
|
PluginRegistry.reset()
|
|
|
|
|
|
def test_install_and_list(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
plugins_dir = tmp_path / "plugins"
|
|
mgr = PluginManager(plugins_dir=plugins_dir, config_path=config_path)
|
|
loaded = mgr.install_path(_FIXTURE, force=True)
|
|
assert loaded.manifest.id == "echo-tool"
|
|
items = mgr.list_installed()
|
|
assert any(i.get("id") == "echo-tool" for i in items)
|
|
|
|
|
|
def test_global_disable(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(
|
|
json.dumps({"plugins": {"echo-tool": {"enabled": False}}}),
|
|
encoding="utf-8",
|
|
)
|
|
plugins_dir = tmp_path / "plugins"
|
|
mgr = PluginManager(plugins_dir=plugins_dir, config_path=config_path)
|
|
mgr.install_path(_FIXTURE, force=True)
|
|
loaded = mgr.load_installed(install_deps=False)
|
|
assert loaded == []
|
|
|
|
|
|
def test_set_enabled_roundtrip(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
mgr.install_path(_FIXTURE, force=True)
|
|
assert any(i.get("id") == "echo-tool" and i.get("enabled") for i in mgr.list_installed())
|
|
|
|
disabled = mgr.set_enabled("echo-tool", False)
|
|
assert disabled.get("enabled") is False
|
|
assert disabled.get("loaded") is False
|
|
assert PluginRegistry().get("echo-tool") is None
|
|
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert raw["plugins"]["echo-tool"]["enabled"] is False
|
|
|
|
enabled = mgr.set_enabled("echo-tool", True)
|
|
assert enabled.get("enabled") is True
|
|
assert enabled.get("loaded") is True
|
|
assert PluginRegistry().get("echo-tool") is not None
|
|
|
|
|
|
def test_normalize_github_blob_url() -> None:
|
|
blob = "https://github.com/veenyi/octop-plugins/blob/main/octop-toolkit.zip"
|
|
assert (
|
|
normalize_plugin_download_url(blob)
|
|
== "https://raw.githubusercontent.com/veenyi/octop-plugins/main/octop-toolkit.zip"
|
|
)
|
|
raw_page = "https://github.com/veenyi/octop-plugins/raw/main/octop-toolkit.zip"
|
|
assert (
|
|
normalize_plugin_download_url(raw_page)
|
|
== "https://raw.githubusercontent.com/veenyi/octop-plugins/main/octop-toolkit.zip"
|
|
)
|
|
already_raw = "https://raw.githubusercontent.com/veenyi/octop-plugins/main/octop-toolkit.zip"
|
|
assert normalize_plugin_download_url(already_raw) == already_raw
|
|
|
|
|
|
def test_install_url_rejects_non_zip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
|
|
def fake_retrieve(
|
|
url: str, filename: str | Path, *args: Any, **kwargs: Any
|
|
) -> tuple[str, None]:
|
|
Path(filename).write_text("<!DOCTYPE html><html>blob page</html>", encoding="utf-8")
|
|
return (str(filename), None)
|
|
|
|
monkeypatch.setattr(
|
|
"octop.infra.agents.plugins.manager.urllib.request.urlretrieve",
|
|
fake_retrieve,
|
|
)
|
|
with pytest.raises(OctopError) as excinfo:
|
|
mgr.install_url("https://example.com/not-a-plugin.zip")
|
|
assert excinfo.value.code is ErrorCode.PLUGIN_INVALID_ARCHIVE
|
|
assert excinfo.value.status == 400
|
|
|
|
|
|
def test_install_url_accepts_valid_zip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
for path in _FIXTURE.rglob("*"):
|
|
if path.is_file():
|
|
zf.write(path, arcname=f"echo-tool/{path.relative_to(_FIXTURE).as_posix()}")
|
|
|
|
def fake_retrieve(
|
|
url: str, filename: str | Path, *args: Any, **kwargs: Any
|
|
) -> tuple[str, None]:
|
|
Path(filename).write_bytes(buf.getvalue())
|
|
return (str(filename), None)
|
|
|
|
monkeypatch.setattr(
|
|
"octop.infra.agents.plugins.manager.urllib.request.urlretrieve",
|
|
fake_retrieve,
|
|
)
|
|
loaded = mgr.install_url("https://example.com/echo-tool.zip", force=True)
|
|
assert loaded.manifest.id == "echo-tool"
|
|
|
|
|
|
def test_install_archive_accepts_local_zip(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
archive = tmp_path / "echo-tool.zip"
|
|
archive.write_bytes(_echo_zip_bytes())
|
|
loaded = mgr.install_archive(archive, force=True)
|
|
assert loaded.manifest.id == "echo-tool"
|
|
items = mgr.list_installed()
|
|
assert any(i.get("id") == "echo-tool" for i in items)
|
|
|
|
|
|
def test_install_archive_rejects_non_zip(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
archive = tmp_path / "not-a-zip.zip"
|
|
archive.write_text("not a zip", encoding="utf-8")
|
|
with pytest.raises(OctopError) as excinfo:
|
|
mgr.install_archive(archive)
|
|
assert excinfo.value.code is ErrorCode.PLUGIN_INVALID_ARCHIVE
|
|
assert excinfo.value.status == 400
|
|
|
|
|
|
def test_install_path_already_exists(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
mgr.install_path(_FIXTURE, force=True)
|
|
with pytest.raises(OctopError) as excinfo:
|
|
mgr.install_path(_FIXTURE, force=False)
|
|
assert excinfo.value.code is ErrorCode.PLUGIN_ALREADY_EXISTS
|
|
assert excinfo.value.status == 409
|
|
assert excinfo.value.details.get("id") == "echo-tool"
|
|
|
|
|
|
def test_parse_plugin_ui_meta_and_list(tmp_path: Path) -> None:
|
|
plugin_dir = tmp_path / "with-ui"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
"\n".join(
|
|
[
|
|
"id: with-ui",
|
|
"version: 0.1.0",
|
|
"name: With UI",
|
|
"kind: tool",
|
|
"entry: main.py",
|
|
"ui:",
|
|
" entry: ui/dist/index.js",
|
|
" manifest: ui/dist/manifest.json",
|
|
],
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(plugin_dir / "main.py").write_text(
|
|
"def setup(ctx):\n pass\n",
|
|
encoding="utf-8",
|
|
)
|
|
dist = plugin_dir / "ui" / "dist"
|
|
dist.mkdir(parents=True)
|
|
(dist / "index.js").write_text("export function setup() {}", encoding="utf-8")
|
|
(dist / "manifest.json").write_text("{}", encoding="utf-8")
|
|
|
|
assert parse_plugin_ui_meta(plugin_dir) == {
|
|
"entry": "ui/dist/index.js",
|
|
"manifest": "ui/dist/manifest.json",
|
|
}
|
|
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
mgr.install_path(plugin_dir, force=True)
|
|
items = mgr.list_installed()
|
|
row = next(i for i in items if i.get("id") == "with-ui")
|
|
assert row.get("ui") == {
|
|
"entry": "ui/dist/index.js",
|
|
"manifest": "ui/dist/manifest.json",
|
|
}
|
|
resolved = mgr.resolve_ui_file("with-ui", "dist/index.js")
|
|
assert resolved.name == "index.js"
|
|
with pytest.raises(OctopError) as excinfo:
|
|
mgr.resolve_ui_file("with-ui", "../plugin.yaml")
|
|
assert excinfo.value.code is ErrorCode.NOT_FOUND
|
|
|
|
|
|
def test_load_missing_picks_up_cli_install(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
plugins_dir = tmp_path / "plugins"
|
|
mgr = PluginManager(plugins_dir=plugins_dir, config_path=config_path)
|
|
# Simulate CLI install: copy to disk without going through this process registry
|
|
dest = plugins_dir / "echo-tool"
|
|
dest.mkdir(parents=True)
|
|
for path in _FIXTURE.rglob("*"):
|
|
if path.is_file():
|
|
target = dest / path.relative_to(_FIXTURE)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(path.read_bytes())
|
|
|
|
assert PluginRegistry().get("echo-tool") is None
|
|
newly = mgr.load_missing(install_deps=False)
|
|
assert len(newly) == 1
|
|
assert newly[0].manifest.id == "echo-tool"
|
|
assert PluginRegistry().get("echo-tool") is not None
|
|
# Second call is a no-op
|
|
assert mgr.load_missing(install_deps=False) == []
|
|
|
|
|
|
def test_parse_plugin_ui_meta_missing_entry(tmp_path: Path) -> None:
|
|
plugin_dir = tmp_path / "no-ui-file"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
"\n".join(
|
|
[
|
|
"id: no-ui-file",
|
|
"version: 0.1.0",
|
|
"name: No UI File",
|
|
"kind: tool",
|
|
"entry: main.py",
|
|
"ui:",
|
|
" entry: ui/dist/index.js",
|
|
],
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert parse_plugin_ui_meta(plugin_dir) is None
|
|
|
|
|
|
def test_parse_plugin_icon(tmp_path: Path) -> None:
|
|
plugin_dir = tmp_path / "with-icon"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
"\n".join(
|
|
[
|
|
"id: with-icon",
|
|
"version: 0.1.0",
|
|
"name: With Icon",
|
|
"kind: tool",
|
|
"entry: main.py",
|
|
'icon: "🧩"',
|
|
],
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert parse_plugin_icon(plugin_dir) == "🧩"
|
|
assert parse_plugin_icon(tmp_path / "missing") is None
|
|
|
|
file_icon_dir = tmp_path / "file-icon"
|
|
file_icon_dir.mkdir()
|
|
(file_icon_dir / "icon.svg").write_text(
|
|
'<svg xmlns="http://www.w3.org/2000/svg"/>',
|
|
encoding="utf-8",
|
|
)
|
|
(file_icon_dir / "plugin.yaml").write_text(
|
|
"\n".join(
|
|
[
|
|
"id: file-icon",
|
|
"version: 0.1.0",
|
|
"name: File Icon",
|
|
"kind: tool",
|
|
"entry: main.py",
|
|
"icon: icon.svg",
|
|
],
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert parse_plugin_icon(file_icon_dir) == "/api/plugins/file-icon/ui/icon.svg"
|
|
|
|
|
|
def test_parse_plugin_group(tmp_path: Path) -> None:
|
|
plugin_dir = tmp_path / "grouped"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
"\n".join(
|
|
[
|
|
"id: grouped",
|
|
"version: 0.1.0",
|
|
"name: Grouped",
|
|
"kind: tool",
|
|
"entry: main.py",
|
|
"group: games",
|
|
],
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert parse_plugin_group(plugin_dir) == "games"
|
|
assert parse_plugin_group(tmp_path / "missing") is None
|
|
|
|
|
|
def test_set_enabled_refuses_corrupt_config_and_preserves_bytes(tmp_path: Path) -> None:
|
|
"""issue #730: the dashboard toggle must not wipe an unparseable config.json."""
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
mgr.install_path(_FIXTURE, force=True)
|
|
|
|
corrupt = '{"bind_host": "0.0.0.0", "database": {"driver": "postgresql"},}'
|
|
config_path.write_text(corrupt, encoding="utf-8")
|
|
with pytest.raises(OctopError) as excinfo:
|
|
mgr.set_enabled("echo-tool", False)
|
|
assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT
|
|
assert config_path.read_text(encoding="utf-8") == corrupt
|
|
|
|
|
|
def test_install_plugin_importing_legacy_harness_agent(tmp_path: Path) -> None:
|
|
"""Plugins published before the octop_harness rename still import harness_agent."""
|
|
src = tmp_path / "legacy-plugin"
|
|
src.mkdir()
|
|
(src / "plugin.yaml").write_text(
|
|
"id: legacy-plugin\nversion: 0.1.0\nname: Legacy\nkind: tool\nentry: main.py\n",
|
|
encoding="utf-8",
|
|
)
|
|
(src / "main.py").write_text(
|
|
"from harness_agent.plugins import PluginContext\n"
|
|
"from harness_agent.plugins.context import PluginContext as Ctx\n"
|
|
"\n"
|
|
"def ping() -> str:\n"
|
|
" return 'pong'\n"
|
|
"\n"
|
|
"def setup(ctx: PluginContext) -> None:\n"
|
|
" if Ctx is not PluginContext:\n"
|
|
" raise RuntimeError('legacy PluginContext is not the installed class')\n"
|
|
" ctx.tool('ping', ping, description='ping')\n",
|
|
encoding="utf-8",
|
|
)
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("{}", encoding="utf-8")
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
loaded = mgr.install_path(src, force=True)
|
|
assert loaded.manifest.id == "legacy-plugin"
|
|
assert [tool.name for tool in loaded.tools] == ["ping"]
|
|
|
|
import octop_harness
|
|
from harness_agent.plugins import PluginContext
|
|
from octop_harness.plugins import PluginContext as RealContext
|
|
|
|
assert PluginContext is RealContext
|
|
assert octop_harness.__name__ == "octop_harness"
|
|
assert sys.modules["harness_agent"] is octop_harness
|
|
|
|
|
|
def test_set_enabled_preserves_unrelated_keys(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"bind_host": "0.0.0.0",
|
|
"database": {"driver": "postgresql", "host": "db.internal"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path)
|
|
mgr.install_path(_FIXTURE, force=True)
|
|
mgr.set_enabled("echo-tool", False)
|
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert data["bind_host"] == "0.0.0.0"
|
|
assert data["database"] == {"driver": "postgresql", "host": "db.internal"}
|
|
assert data["plugins"]["echo-tool"]["enabled"] is False
|
|
|
|
|
|
def test_channel_reload_preserves_class_until_source_changes(tmp_path: Path) -> None:
|
|
from octop_harness.plugins.context import PluginContext
|
|
|
|
if not hasattr(PluginContext, "channel"):
|
|
pytest.skip("released harness lacks channel registration; exercised with companion source")
|
|
config = tmp_path / "config.json"
|
|
config.write_text("{}")
|
|
directory = tmp_path / "plugins" / "test-channel"
|
|
directory.mkdir(parents=True)
|
|
(directory / "plugin.yaml").write_text(
|
|
"id: test-channel\nversion: 1\nkind: channel\nentry: main.py\n"
|
|
)
|
|
source = directory / "main.py"
|
|
source.write_text('class Channel: pass\ndef setup(ctx): ctx.channel("acme", Channel)\n')
|
|
manager = PluginManager(plugins_dir=directory.parent, config_path=config)
|
|
first = manager.load_installed(install_deps=False)[0].channels[0].channel_cls
|
|
second = manager.load_installed(install_deps=False)[0].channels[0].channel_cls
|
|
assert first is second
|
|
source.write_text(source.read_text() + "\n# updated implementation\n")
|
|
third = manager.load_installed(install_deps=False)[0].channels[0].channel_cls
|
|
assert third is not first
|