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}')]