Files
Octop/tests/unit/test_plugin_manager.py
ffd6c3086c feat(channels): 打通通道插件安装与运行生命周期 (#1823)
* 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>
2026-10-16 20:55:34 +08:00

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