feat: add caller context to plugin config hooks

Alessandro committed Aug 12, 2026 at 03:17 UTC 1121758a6fbc55c4d011ecfbeef6284faed6a089
3 files changed +77 -3
helpers/plugins.py
+9 -1
@@ -45,6 +45,7 @@ _META_TARGET_RE = re.compile(
45
46
47 type ToggleState = Literal["enabled", "disabled"]
48 +type CallerContext = Literal["ui", "agent", "api"]
49
50
51 class PluginAssetFile(TypedDict):
@@ -590,6 +591,7 @@ def get_plugin_config(
591 agent: Agent | None = None,
592 project_name: str | None = None,
593 agent_profile: str | None = None,
594 + caller: CallerContext = "api",
595 ):
596
597 default_used = False
@@ -635,6 +637,7 @@ def get_plugin_config(
637 agent=agent,
638 project_name=project_name,
639 agent_profile=agent_profile,
640 + hook_context={"caller": caller},
641 )
642
643 return result
@@ -663,7 +666,11 @@ def get_default_plugin_config(plugin_name: str):
666
667 @extension.extensible
668 def save_plugin_config(
666 - plugin_name: str, project_name: str, agent_profile: str, settings: dict
669 + plugin_name: str,
670 + project_name: str,
671 + agent_profile: str,
672 + settings: dict,
673 + caller: CallerContext = "api",
674 ):
675 file_path = determine_plugin_asset_path(
676 plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
@@ -677,6 +684,7 @@ def save_plugin_config(
684 project_name=project_name,
685 agent_profile=agent_profile,
686 settings=settings,
687 + hook_context={"caller": caller},
688 )
689
690 # or do standard load
helpers/plugins.py.dox.md
+4 -2
@@ -35,9 +35,9 @@
35 - `determined_toggle_from_paths(default: bool, paths: Iterator[str])`
36 - `get_toggle_state(plugin_name: str) -> ToggleState`
37 - `toggle_plugin(plugin_name: str, enabled: bool, project_name: str=..., agent_profile: str=..., clear_overrides: bool=...)`
38 -- `get_plugin_config(plugin_name: str, agent: Agent | None=..., project_name: str | None=..., agent_profile: str | None=...)`
38 +- `get_plugin_config(plugin_name: str, agent: Agent | None=..., project_name: str | None=..., agent_profile: str | None=..., caller: CallerContext=...)`
39 - `get_default_plugin_config(plugin_name: str)`
40 -- `save_plugin_config(plugin_name: str, project_name: str, agent_profile: str, settings: dict)`
40 +- `save_plugin_config(plugin_name: str, project_name: str, agent_profile: str, settings: dict, caller: CallerContext=...)`
41 - `find_plugin_asset(plugin_name: str, *subpaths, project_name=..., agent_profile=...)`
42 - `find_plugin_assets(*subpaths, plugin_name: str=..., project_name: str=..., agent_profile: str=..., only_first: bool=...) -> list[PluginAssetFile]`
43 - `determine_plugin_asset_path(plugin_name: str, project_name: str, agent_profile: str, *subpaths)`
@@ -51,6 +51,8 @@
51 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
52 - Plugins marked `always_enabled` remain in runtime discovery regardless of
53 stale global or scoped disable files, and disable attempts are rejected.
54 +- Config hooks receive `hook_context={"caller": caller}` with one of `ui`,
55 + `agent`, or `api`; this is behavioral context, not an authorization boundary.
56 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
57 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling.
58 - Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`.
tests/test_plugin_hook_context.py new
+64
@@ -0,0 +1,64 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +
5 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
6 +if str(PROJECT_ROOT) not in sys.path:
7 + sys.path.insert(0, str(PROJECT_ROOT))
8 +
9 +from helpers import plugins
10 +
11 +
12 +def _capture_hook_context(monkeypatch):
13 + captured = {}
14 +
15 + def call_plugin_hook(plugin_name, hook_name, default=None, **kwargs):
16 + captured.update(
17 + plugin_name=plugin_name,
18 + hook_name=hook_name,
19 + hook_context=kwargs["hook_context"],
20 + )
21 + return default
22 +
23 + monkeypatch.setattr(plugins, "call_plugin_hook", call_plugin_hook)
24 + return captured
25 +
26 +
27 +def test_get_plugin_config_forwards_caller_to_hook(monkeypatch):
28 + captured = _capture_hook_context(monkeypatch)
29 + monkeypatch.setattr(
30 + plugins, "find_plugin_asset", lambda *_args, **_kwargs: {"path": "config.json"}
31 + )
32 + monkeypatch.setattr(plugins.files, "exists", lambda _path: True)
33 + monkeypatch.setattr(plugins.files, "read_file", lambda _path: '{"enabled": true}')
34 +
35 + assert plugins.get_plugin_config.__wrapped__("example", caller="ui") == {
36 + "enabled": True
37 + }
38 + assert captured == {
39 + "plugin_name": "example",
40 + "hook_name": "get_plugin_config",
41 + "hook_context": {"caller": "ui"},
42 + }
43 +
44 +
45 +def test_save_plugin_config_forwards_caller_to_hook(monkeypatch):
46 + captured = _capture_hook_context(monkeypatch)
47 + saved = []
48 + monkeypatch.setattr(
49 + plugins, "determine_plugin_asset_path", lambda *_args, **_kwargs: "config.json"
50 + )
51 + monkeypatch.setattr(
52 + plugins.files, "write_file", lambda path, content: saved.append((path, content))
53 + )
54 +
55 + plugins.save_plugin_config.__wrapped__(
56 + "example", "", "", {"enabled": True}, caller="agent"
57 + )
58 +
59 + assert captured == {
60 + "plugin_name": "example",
61 + "hook_name": "save_plugin_config",
62 + "hook_context": {"caller": "agent"},
63 + }
64 + assert saved == [("config.json", '{"enabled": true}')]