From 1121758a6fbc55c4d011ecfbeef6284faed6a089 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:17:28 +0200 Subject: [PATCH] feat: add caller context to plugin config hooks --- helpers/plugins.py | 10 ++++- helpers/plugins.py.dox.md | 6 ++- tests/test_plugin_hook_context.py | 64 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 tests/test_plugin_hook_context.py diff --git a/helpers/plugins.py b/helpers/plugins.py index 73db58f6d..14d9a37be 100644 --- a/helpers/plugins.py +++ b/helpers/plugins.py @@ -45,6 +45,7 @@ _META_TARGET_RE = re.compile( type ToggleState = Literal["enabled", "disabled"] +type CallerContext = Literal["ui", "agent", "api"] class PluginAssetFile(TypedDict): @@ -590,6 +591,7 @@ def get_plugin_config( agent: Agent | None = None, project_name: str | None = None, agent_profile: str | None = None, + caller: CallerContext = "api", ): default_used = False @@ -635,6 +637,7 @@ def get_plugin_config( agent=agent, project_name=project_name, agent_profile=agent_profile, + hook_context={"caller": caller}, ) return result @@ -663,7 +666,11 @@ def get_default_plugin_config(plugin_name: str): @extension.extensible def save_plugin_config( - plugin_name: str, project_name: str, agent_profile: str, settings: dict + plugin_name: str, + project_name: str, + agent_profile: str, + settings: dict, + caller: CallerContext = "api", ): file_path = determine_plugin_asset_path( plugin_name, project_name, agent_profile, CONFIG_FILE_NAME @@ -677,6 +684,7 @@ def save_plugin_config( project_name=project_name, agent_profile=agent_profile, settings=settings, + hook_context={"caller": caller}, ) # or do standard load diff --git a/helpers/plugins.py.dox.md b/helpers/plugins.py.dox.md index 45bd1fbef..e5c1e77e1 100644 --- a/helpers/plugins.py.dox.md +++ b/helpers/plugins.py.dox.md @@ -35,9 +35,9 @@ - `determined_toggle_from_paths(default: bool, paths: Iterator[str])` - `get_toggle_state(plugin_name: str) -> ToggleState` - `toggle_plugin(plugin_name: str, enabled: bool, project_name: str=..., agent_profile: str=..., clear_overrides: bool=...)` -- `get_plugin_config(plugin_name: str, agent: Agent | None=..., project_name: str | None=..., agent_profile: str | None=...)` +- `get_plugin_config(plugin_name: str, agent: Agent | None=..., project_name: str | None=..., agent_profile: str | None=..., caller: CallerContext=...)` - `get_default_plugin_config(plugin_name: str)` -- `save_plugin_config(plugin_name: str, project_name: str, agent_profile: str, settings: dict)` +- `save_plugin_config(plugin_name: str, project_name: str, agent_profile: str, settings: dict, caller: CallerContext=...)` - `find_plugin_asset(plugin_name: str, *subpaths, project_name=..., agent_profile=...)` - `find_plugin_assets(*subpaths, plugin_name: str=..., project_name: str=..., agent_profile: str=..., only_first: bool=...) -> list[PluginAssetFile]` - `determine_plugin_asset_path(plugin_name: str, project_name: str, agent_profile: str, *subpaths)` @@ -51,6 +51,8 @@ - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. - Plugins marked `always_enabled` remain in runtime discovery regardless of stale global or scoped disable files, and disable attempts are rejected. +- Config hooks receive `hook_context={"caller": caller}` with one of `ui`, + `agent`, or `api`; this is behavioral context, not an authorization boundary. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling. - Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`. diff --git a/tests/test_plugin_hook_context.py b/tests/test_plugin_hook_context.py new file mode 100644 index 000000000..7bb5a0743 --- /dev/null +++ b/tests/test_plugin_hook_context.py @@ -0,0 +1,64 @@ +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import plugins + + +def _capture_hook_context(monkeypatch): + captured = {} + + def call_plugin_hook(plugin_name, hook_name, default=None, **kwargs): + captured.update( + plugin_name=plugin_name, + hook_name=hook_name, + hook_context=kwargs["hook_context"], + ) + return default + + monkeypatch.setattr(plugins, "call_plugin_hook", call_plugin_hook) + return captured + + +def test_get_plugin_config_forwards_caller_to_hook(monkeypatch): + captured = _capture_hook_context(monkeypatch) + monkeypatch.setattr( + plugins, "find_plugin_asset", lambda *_args, **_kwargs: {"path": "config.json"} + ) + monkeypatch.setattr(plugins.files, "exists", lambda _path: True) + monkeypatch.setattr(plugins.files, "read_file", lambda _path: '{"enabled": true}') + + assert plugins.get_plugin_config.__wrapped__("example", caller="ui") == { + "enabled": True + } + assert captured == { + "plugin_name": "example", + "hook_name": "get_plugin_config", + "hook_context": {"caller": "ui"}, + } + + +def test_save_plugin_config_forwards_caller_to_hook(monkeypatch): + captured = _capture_hook_context(monkeypatch) + saved = [] + monkeypatch.setattr( + plugins, "determine_plugin_asset_path", lambda *_args, **_kwargs: "config.json" + ) + monkeypatch.setattr( + plugins.files, "write_file", lambda path, content: saved.append((path, content)) + ) + + plugins.save_plugin_config.__wrapped__( + "example", "", "", {"enabled": True}, caller="agent" + ) + + assert captured == { + "plugin_name": "example", + "hook_name": "save_plugin_config", + "hook_context": {"caller": "agent"}, + } + assert saved == [("config.json", '{"enabled": true}')]