A plugin manifest's `config_schema` now reaches the Desktop: `plugins.manage list` returns each plugin's schema with the current `plugins.entries.<id>.settings` values (`settings_schema`), and a new `settings` action writes edits through `hermes_cli.plugins_state.save_plugin_setting` — the writer extracted from `PluginContext.set_config`, so the plugin, the CLI and the Desktop share one config path, one lock and the same managed-install / managed-key refusals. The Plugins tab grows a gear per plugin with a schema; the inline form is table-driven (`FIELD_CONTROLS` / `INITIAL_TEXT` / `COERCE` keyed on the wire field type) for string / number / boolean / enum / json / secret. Secrets are declared with `type: secret`: the row carries only the `.env` name and a presence flag, the client writes the value through the existing `PUT /api/env` credential route, and the RPC refuses secret keys so nothing lands in config.yaml. Contracts regenerated; docs gain a "Settings form in the Desktop" section.
67 lines
3.3 KiB
Python
67 lines
3.3 KiB
Python
"""Gateway ``plugins.manage`` — manifest ``config_schema`` rendered as settings fields (#46600, #87934).
|
|
|
|
Drives the real discovery + config writer against a temp HERMES_HOME: ``list`` carries each user
|
|
plugin's schema with the current values, and ``settings`` writes through the same
|
|
``plugins.entries.<id>.settings`` namespace ``ctx.get_config`` reads — never a secret into config.yaml.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from tui_gateway import server
|
|
|
|
MANIFEST = """\
|
|
name: demo-plugin
|
|
version: 1.0.0
|
|
config_schema:
|
|
api_url: {type: str, default: "https://example.invalid", description: "Service endpoint"}
|
|
retries: {type: int, default: 3}
|
|
verbose: {type: bool, default: false}
|
|
mode: {type: str, choices: [fast, careful], default: fast}
|
|
api_key: {type: secret, description: "Token"}
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def plugins_home(tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes-home"
|
|
(home / "plugins" / "demo-plugin").mkdir(parents=True)
|
|
(home / "plugins" / "demo-plugin" / "plugin.yaml").write_text(MANIFEST, encoding="utf-8")
|
|
(home / "config.yaml").write_text(
|
|
"plugins:\n entries:\n demo-plugin:\n settings:\n retries: 7\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
return home
|
|
|
|
|
|
def _manage(**params):
|
|
return server.handle_request({"id": "1", "method": "plugins.manage", "params": params})
|
|
|
|
|
|
def test_list_carries_schema_fields_with_current_values_and_no_secret_values(plugins_home, monkeypatch):
|
|
monkeypatch.setenv("DEMO_PLUGIN_API_KEY", "shh")
|
|
|
|
rows = _manage(action="list")["result"]["plugins"]
|
|
row = next(r for r in rows if r["key"] == "demo-plugin")
|
|
fields = {f["key"]: f for f in row["settings_schema"]}
|
|
|
|
assert fields["api_url"]["type"] == "string" and fields["api_url"]["value"] == "https://example.invalid"
|
|
assert fields["retries"]["type"] == "number" and fields["retries"]["value"] == 7 # config.yaml wins over default
|
|
assert fields["verbose"]["type"] == "boolean" and fields["verbose"]["value"] is False
|
|
assert fields["mode"]["type"] == "enum" and fields["mode"]["choices"] == ["fast", "careful"]
|
|
assert fields["api_key"] == {"key": "api_key", "type": "secret", "label": "api_key", "description": "Token",
|
|
"required": False, "env": "DEMO_PLUGIN_API_KEY", "has_value": True}
|
|
|
|
|
|
def test_settings_writes_the_plugin_namespace_and_refuses_secrets_and_bad_types(plugins_home):
|
|
resp = _manage(action="settings", key="demo-plugin", values={"api_url": "https://real.invalid", "retries": 2, "mode": "careful"})
|
|
|
|
assert resp["result"]["ok"] is True and sorted(resp["result"]["written"]) == ["api_url", "mode", "retries"]
|
|
from hermes_cli.config import load_config_readonly
|
|
assert load_config_readonly()["plugins"]["entries"]["demo-plugin"]["settings"] == {
|
|
"api_url": "https://real.invalid", "retries": 2, "mode": "careful"}
|
|
refreshed = {f["key"]: f["value"] for f in resp["result"]["plugin"]["settings_schema"] if "value" in f}
|
|
assert refreshed["retries"] == 2 and refreshed["mode"] == "careful"
|
|
|
|
for values in ({"api_key": "leak"}, {"retries": "two"}, {"mode": "reckless"}, {"unknown": 1}):
|
|
assert _manage(action="settings", key="demo-plugin", values=values)["error"]["code"] == 4021
|
|
assert "api_key" not in (plugins_home / "config.yaml").read_text(encoding="utf-8")
|