Harden scoped agent profile mutations

Validate destructive requests and project-derived scopes at the Agent Editor API boundary, and reuse the running-profile guard for deletion. Recheck creation and availability invariants inside the existing mutation boundary, preserve project availability atomically through its dedicated owner, and keep Project Edit and Settings catalogs from replaying stale or unavailable profile state. Add focused regressions for confirmation, collisions, malformed project metadata, avatar failures, running profiles, and sparse project updates.

Alessandro committed Aug 10, 2026 at 05:08 UTC 7545eef2983c9db9d0dcc41cde99a5aa4e14e6e6
11 files changed +387 -56
helpers/projects.py
+35 -7
@@ -64,7 +64,6 @@ class EditProjectData(BasicProjectData):
64 variables: str
65 secrets: str
66 mcp_servers: str
67 - subagents: dict[str, SubAgentSettings]
67 git_status: GitStatusData
68
69
@@ -72,7 +71,7 @@ ProjectExtendedData = dict[str, object]
71 _PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset(
72 EditProjectData.__annotations__
73 )
75 -_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"})
74 +_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token", "subagents"})
75
76
77 def get_projects_parent_folder():
@@ -227,7 +226,6 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData:
226 "file_structure",
227 _default_file_structure_settings(),
228 ),
230 - "subagents": data.get("subagents", {}),
229 }
230 return normalized
231
@@ -246,7 +244,6 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData:
244 "knowledge_files_count": 0,
245 "variables": "",
246 "secrets": "",
249 - "subagents": {},
247 "git_status": {"is_git_repo": False},
248 },
249 )
@@ -269,7 +266,6 @@ def update_project(name: str, data: EditProjectData):
266 save_project_variables(name, current["variables"])
267 save_project_secrets(name, current["secrets"])
268 save_project_mcp_servers(name, current["mcp_servers"])
272 - save_project_subagents(name, current["subagents"])
269 save_project_extended_data(name, extended_data)
270
271 reactivate_project_in_chats(name)
@@ -291,7 +287,6 @@ def load_edit_project_data(name: str) -> EditProjectData:
287 variables = load_project_variables(name)
288 mcp_servers = load_project_mcp_servers(name)
289 secrets = load_project_secrets_masked(name)
294 - subagents = load_project_subagents(name)
290 knowledge_files_count = get_knowledge_files_count(name)
291 git_status = cast(GitStatusData, git.get_repo_status(get_project_folder(name)))
292
@@ -305,7 +300,6 @@ def load_edit_project_data(name: str) -> EditProjectData:
300 "variables": variables,
301 "mcp_servers": mcp_servers,
302 "secrets": secrets,
308 - "subagents": subagents,
303 "git_status": git_status,
304 },
305 )
@@ -709,6 +703,40 @@ def save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings
703 files.write_file(abs_path, content)
704
705
706 +def set_project_subagent_enabled(name: str, profile_id: str, enabled: bool) -> None:
707 + from helpers import subagents
708 +
709 + name = validate_project_name(name)
710 + if not os.path.isdir(get_project_folder(name)):
711 + raise ValueError("Project not found.")
712 + if not isinstance(enabled, bool):
713 + raise ValueError("Agent availability must be true or false.")
714 + agent = subagents.get_agents_dict(name).get(profile_id)
715 + if not agent:
716 + raise ValueError(f'Agent profile "{profile_id}" does not exist.')
717 +
718 + path = get_project_meta(name, "agents.json")
719 + try:
720 + settings = dirty_json.parse(files.read_file(path))
721 + except FileNotFoundError:
722 + settings = {}
723 + except Exception as exc:
724 + raise ValueError("Project agent availability is invalid.") from exc
725 + if not isinstance(settings, dict) or any(
726 + not isinstance(key, str)
727 + or not isinstance(value, dict)
728 + or not isinstance(value.get("enabled"), bool)
729 + for key, value in settings.items()
730 + ):
731 + raise ValueError("Project agent availability is invalid.")
732 +
733 + if agent.enabled == enabled:
734 + settings.pop(profile_id, None)
735 + else:
736 + settings[profile_id] = {"enabled": enabled}
737 + save_project_subagents(name, settings)
738 +
739 +
740 def _normalize_subagents(
741 subagents_data: dict[str, SubAgentSettings], project_name: str = ""
742 ) -> dict[str, SubAgentSettings]:
helpers/projects.py.dox.md
+6 -1
@@ -58,6 +58,7 @@
58 - `_normalize_include_agents_md(value: object) -> bool`
59 - `load_project_subagents(name: str) -> dict[str, SubAgentSettings]`
60 - `save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings])`
61 +- `set_project_subagent_enabled(name: str, profile_id: str, enabled: bool) -> None`
62 - `_normalize_subagents(subagents_data: dict[str, SubAgentSettings], project_name: str=...) -> dict[str, SubAgentSettings]`
63 - Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_SKILLS_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_MCP_SERVERS_FILE`, `PROJECT_AGENTS_MD_FILES`, `DEFAULT_MCP_SERVERS_CONFIG`, `CONTEXT_DATA_KEY_PROJECT`.
64
@@ -79,7 +80,11 @@
80 configured default profile, then `agent0`, then the first available profile.
81 - Per-project profile availability is persisted sparsely in `.a0proj/agents.json`;
82 entries matching the profile definition's scoped default are omitted. The
82 - helper retains the established plain load/save contract for project settings.
83 + helper retains the established tolerant load contract for read-only settings.
84 + Profile-scoped mutations re-read the file strictly, preserve unrelated
85 + entries, refuse malformed data, and write through `helpers.files`. General
86 + project edit payloads neither expose nor mutate profile availability; legacy
87 + `subagents` input is ignored.
88 - Profile reconciliation treats `None` as the Global scope. Callers must pass
89 `all_scopes=True` to check every loaded chat after a Global availability
90 change. Each pass resolves the available profile catalog once per encountered
helpers/settings.py
+14 -4
@@ -258,9 +258,12 @@ def convert_out(settings: Settings) -> SettingsOutput:
258 chat_providers=get_providers("chat"),
259 embedding_providers=get_providers("embedding"),
260 is_dockerized=runtime.is_dockerized(),
261 - agent_subdirs=[{"value": item["key"], "label": item["label"]}
262 - for item in subagents.get_all_agents_list()
263 - if item["key"] != "_example"],
261 + agent_subdirs=[
262 + {"value": key, "label": item.title or key}
263 + for key, item in sorted(
264 + subagents.get_available_agents_dict(None).items()
265 + )
266 + ],
267 knowledge_subdirs=[{"value": subdir, "label": subdir}
268 for subdir in files.get_subdirectories("knowledge", exclude="default")],
269 timezones=_timezone_options(),
@@ -284,7 +287,14 @@ def convert_out(settings: Settings) -> SettingsOutput:
287 ),
288 }
289
287 - additional["agent_subdirs"] = _ensure_option_present(additional.get("agent_subdirs"), current.get("agent_profile"))
290 + current_profile = current.get("agent_profile")
291 + if current_profile and not any(
292 + option["value"] == current_profile
293 + for option in additional["agent_subdirs"]
294 + ):
295 + additional["agent_subdirs"].append(
296 + {"value": current_profile, "label": f"{current_profile} (unavailable)"}
297 + )
298 additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir"))
299 if current.get("timezone") != TIMEZONE_AUTO:
300 additional["timezones"] = _ensure_option_present(additional.get("timezones"), current.get("timezone"))
helpers/settings.py.dox.md
+3
@@ -67,6 +67,9 @@
67 - Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change.
68 - `max_consecutive_unusable_responses` defaults to `5` and controls the cost circuit breaker for malformed or repeated main-model outputs.
69 - `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, and right canvas rail; missing or malformed values fall back per device.
70 +- The Global default-profile selector lists only globally available profiles.
71 + A currently configured unavailable profile remains visible with an explicit
72 + unavailable label so settings can round-trip it truthfully.
73 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
74
75 ## Work Guidance
plugins/_agent_editor/AGENTS.md
+7
@@ -26,6 +26,11 @@
26 - Every profile, including `Default`, can be made unavailable. The backend
27 rejects only the change that would leave the selected scope with no available
28 profile, then reconciles loaded chats through the shared project owner.
29 +- Destructive cleanup and custom-profile deletion require an explicit confirmed
30 + apply request. Profile creation and availability invariants are rechecked at
31 + the existing editor mutation boundary; project availability refuses malformed
32 + `agents.json` and changes only the requested profile entry through the existing
33 + project storage owner.
34 - Never call `helpers.subagents.save_agent_data`.
35 - Authored profile definitions remain YAML; editor-written plugin configs remain
36 JSON.
@@ -39,6 +44,8 @@
44 - Advanced prompt text is directly editable; per-file close/check actions
45 discard or accept the current edit checkpoint, while the editor's global save
46 remains the only persistence boundary.
47 +- New profiles require a display name and non-empty agent instructions in both
48 + Easy and Advanced; existing Advanced prompt edits retain per-file semantics.
49 - The configurable tool catalog is visible in both modes; Easy provides direct
50 allow/block checkboxes and points to Advanced for skill access. Skills remain
51 Advanced-only. Advanced keeps both complete selectors visible but disabled
plugins/_agent_editor/README.md
+1 -1
@@ -16,4 +16,4 @@ and can be removed without changing it.
16 Manage agents can duplicate the effective profile into the selected scope and
17 toggle whether each profile is available there. Project availability reuses
18 `.a0proj/agents.json`; Global availability is a sparse profile override.
19 -The Default profile always remains available.
19 +The selected scope must always keep at least one profile available.
plugins/_agent_editor/api/agent_editor.py
+47 -22
@@ -41,20 +41,7 @@ class AgentEditor(ApiHandler):
41 if not isinstance(enabled, bool):
42 raise ValueError("Agent availability must be true or false.")
43 if not enabled:
44 - project_name = editor._context_project_name(context)
45 - for live_context in AgentContext.all():
46 - if (
47 - getattr(live_context.config, "profile", "") == profile_id
48 - and live_context.is_running()
49 - and (
50 - not project_name
51 - or projects.get_context_project_name(live_context)
52 - == project_name
53 - )
54 - ):
55 - raise ValueError(
56 - "This agent is running. Disable it after the run finishes."
57 - )
44 + _reject_running_profile(profile_id, context, "Disable")
45 receipt = editor.set_profile_enabled(profile_id, enabled, context)
46 return {
47 "ok": True,
@@ -74,10 +61,21 @@ class AgentEditor(ApiHandler):
61 }
62 if action in {"plan_remove_changes", "remove_changes"}:
63 profile_id = editor.validate_profile_id(input.get("profile_id"))
64 + destructive = input.get("destructive", False)
65 + if not isinstance(destructive, bool):
66 + raise ValueError("Destructive removal must be true or false.")
67 + if (
68 + action == "remove_changes"
69 + and destructive
70 + and input.get("confirm") is not True
71 + ):
72 + raise ValueError(
73 + "Deleting all profile customizations requires confirmation."
74 + )
75 plan = editor.plan_remove_changes(
76 profile_id,
77 context,
80 - destructive=bool(input.get("destructive")),
78 + destructive=destructive,
79 )
80 if action == "plan_remove_changes":
81 return {"ok": True, **plan.response()}
@@ -89,6 +87,12 @@ class AgentEditor(ApiHandler):
87 }
88 if action in {"plan_delete", "delete"}:
89 profile_id = editor.validate_profile_id(input.get("profile_id"))
90 + if action == "delete":
91 + if input.get("confirm") is not True:
92 + raise ValueError(
93 + "Deleting a custom agent requires confirmation."
94 + )
95 + _reject_running_profile(profile_id, context, "Delete")
96 plan = editor.plan_delete_custom(profile_id, context)
97 if action == "plan_delete":
98 return {
@@ -96,8 +100,6 @@ class AgentEditor(ApiHandler):
100 **plan.response(),
101 "impact": editor.delete_impact(profile_id, context),
102 }
99 - if input.get("confirm") is not True:
100 - raise ValueError("Deleting a custom agent requires confirmation.")
103 receipt = editor.apply_change_plan(plan)
104 project_name = editor._context_project_name(context)
105 projects.reconcile_agent_profiles(
@@ -115,15 +117,38 @@ def _context(input: dict[str, Any]) -> Any:
117 context = AgentContext.get(context_id)
118 if not context:
119 raise ValueError("Chat context not found.")
120 + _validate_project_scope(projects.get_context_project_name(context))
121 return context
119 - project_name = str(input.get("project_name") or "").strip()
120 - if project_name:
121 - project_name = projects.validate_project_name(project_name)
122 - if not Path(projects.get_project_folder(project_name)).is_dir():
123 - raise ValueError("Project not found.")
122 + project_name = _validate_project_scope(input.get("project_name"))
123 return editor._EditorContext(project_name)
124
125
126 +def _validate_project_scope(project_name: Any) -> str:
127 + value = str(project_name or "").strip()
128 + if not value:
129 + return ""
130 + value = projects.validate_project_name(value)
131 + if not Path(projects.get_project_folder(value)).is_dir():
132 + raise ValueError("Project not found.")
133 + return value
134 +
135 +
136 +def _reject_running_profile(profile_id: str, context: Any, action: str) -> None:
137 + project_name = editor._context_project_name(context)
138 + for live_context in AgentContext.all():
139 + if (
140 + getattr(live_context.config, "profile", "") == profile_id
141 + and live_context.is_running()
142 + and (
143 + not project_name
144 + or projects.get_context_project_name(live_context) == project_name
145 + )
146 + ):
147 + raise ValueError(
148 + f"This agent is running. {action} it after the run finishes."
149 + )
150 +
151 +
152 def _active_profile(input: dict[str, Any]) -> dict[str, str]:
153 context_id = str(input.get("active_context_id") or "").strip()
154 context = AgentContext.get(context_id) if context_id else None
plugins/_agent_editor/helpers/editor.py
+40 -21
@@ -82,6 +82,7 @@ class ChangePlan:
82 staged_tokens: set[str] = field(default_factory=set)
83 profile_id: str = ""
84 project_name: str = ""
85 + creating: bool = False
86 remove_empty_root: bool = False
87
88 def write(self, path: Path, content: str | bytes) -> None:
@@ -681,7 +682,11 @@ def build_change_plan(
682 if not creating and not exists:
683 raise ValueError(f'Agent profile "{profile_id}" does not exist.')
684
684 - plan = ChangePlan(profile_id=profile_id, project_name=project_name)
685 + plan = ChangePlan(
686 + profile_id=profile_id,
687 + project_name=project_name,
688 + creating=creating,
689 + )
690 if "metadata" in patch:
691 _plan_metadata(plan, patch["metadata"], context, creating=creating)
692 if "prompts" in patch:
@@ -758,25 +763,26 @@ def set_profile_enabled(
763 if not profile_exists(profile_id, context):
764 raise ValueError(f'Agent profile "{profile_id}" does not exist.')
765
761 - available = subagents.get_available_agents_dict(project_name or None)
762 - if not enabled and profile_id in available and len(available) == 1:
763 - raise ValueError("At least one agent profile must remain available.")
764 -
765 - if project_name:
766 - settings = projects.load_project_subagents(project_name)
767 - settings[profile_id] = {"enabled": enabled}
768 - projects.save_project_subagents(project_name, settings)
769 - receipt = {
770 - "written": [
771 - _relative_source(
772 - Path(projects.get_project_meta(project_name, "agents.json"))
773 - )
774 - ],
775 - "deleted": [],
776 - "warnings": [],
777 - }
778 - else:
779 - receipt = apply_change_plan(plan_profile_enabled(profile_id, enabled, context))
766 + with _MUTATION_LOCK:
767 + available = subagents.get_available_agents_dict(project_name or None)
768 + if not enabled and profile_id in available and len(available) == 1:
769 + raise ValueError("At least one agent profile must remain available.")
770 +
771 + if project_name:
772 + projects.set_project_subagent_enabled(project_name, profile_id, enabled)
773 + receipt = {
774 + "written": [
775 + _relative_source(
776 + Path(projects.get_project_meta(project_name, "agents.json"))
777 + )
778 + ],
779 + "deleted": [],
780 + "warnings": [],
781 + }
782 + else:
783 + receipt = apply_change_plan(
784 + plan_profile_enabled(profile_id, enabled, context)
785 + )
786
787 if not enabled:
788 projects.reconcile_agent_profiles(
@@ -807,7 +813,11 @@ def plan_duplicate_profile(
813 target_title = f"{source_title} {index}"
814 project_name = _context_project_name(context)
815 target_root = _profile_root(target_id, project_name)
810 - plan = ChangePlan(profile_id=target_id, project_name=project_name)
816 + plan = ChangePlan(
817 + profile_id=target_id,
818 + project_name=project_name,
819 + creating=True,
820 + )
821
822 metadata: dict[str, Any] = {}
823 for layer in _metadata_layers(profile_id, context):
@@ -1128,6 +1138,13 @@ def apply_change_plan(plan: ChangePlan) -> dict[str, Any]:
1138 receipt = plan.response()
1139
1140 with _MUTATION_LOCK:
1141 + if plan.creating and profile_exists(
1142 + plan.profile_id,
1143 + _EditorContext(project_name),
1144 + ):
1145 + raise ValueError(
1146 + f'Agent profile "{plan.profile_id}" was created before this save completed.'
1147 + )
1148 snapshots = {
1149 change.path: change.path.read_bytes() if change.path.is_file() else None
1150 for change in changes
@@ -1293,6 +1310,8 @@ def stage_avatar(upload: Any) -> dict[str, Any]:
1310 raise ValueError("Avatar must be a valid PNG, JPEG, or WebP image.") from exc
1311 except Image.DecompressionBombError as exc:
1312 raise ValueError("Avatar dimensions are too large.") from exc
1313 + except OSError as exc:
1314 + raise ValueError("Avatar must be a valid PNG, JPEG, or WebP image.") from exc
1315
1316 _cleanup_staged_avatars()
1317 STAGED_AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
tests/test_agent_editor.py
+158
@@ -4,6 +4,7 @@ from io import BytesIO
4 import json
5 from pathlib import Path
6 import stat
7 +from types import SimpleNamespace
8
9 import pytest
10 from werkzeug.datastructures import FileStorage
@@ -480,11 +481,20 @@ def test_project_customizations_inherit_global_agent_and_remove_only_project_fil
481 def test_project_scope_is_validated_at_api_and_apply_boundaries(
482 user_root: Path,
483 project_scope: tuple[editor._EditorContext, Path],
484 + monkeypatch: pytest.MonkeyPatch,
485 ) -> None:
486 context, _project_agents = project_scope
487 assert editor.projects.get_context_project_name(editor_context({"project_name": "demo"})) == "demo"
488 with pytest.raises(ValueError, match="Project not found"):
489 editor_context({"project_name": "missing-agent-editor-project"})
490 + monkeypatch.setattr(
491 + "plugins._agent_editor.api.agent_editor.AgentContext.get",
492 + lambda _context_id: SimpleNamespace(
493 + get_data=lambda _key, recursive=True: "../outside"
494 + ),
495 + )
496 + with pytest.raises(ValueError, match="Invalid project name"):
497 + editor_context({"context_id": "unsafe-project-context"})
498
499 forged = editor.ChangePlan(profile_id="researcher", project_name="demo")
500 forged.write(user_root / "researcher" / "agent.yaml", "title: Wrong scope\n")
@@ -927,6 +937,154 @@ def test_avatar_is_normalized_and_avatar_only_edit_is_sparse(user_root: Path) ->
937 assert not normalized.getexif()
938
939
940 +@pytest.mark.asyncio
941 +async def test_truncated_avatar_is_a_validation_error(user_root: Path) -> None:
942 + from PIL import Image
943 +
944 + source = BytesIO()
945 + Image.new("RGB", (32, 32), "red").save(source, format="PNG")
946 + upload = FileStorage(
947 + stream=BytesIO(source.getvalue()[:-24]),
948 + filename="truncated.png",
949 + )
950 + response = await AgentEditorAvatar(None, None).process( # type: ignore[arg-type]
951 + {},
952 + SimpleNamespace(method="POST", files={"avatar": upload}),
953 + )
954 +
955 + assert response.status_code == 400
956 + assert "valid PNG, JPEG, or WebP" in response.get_data(as_text=True)
957 +
958 +
959 +@pytest.mark.asyncio
960 +async def test_destructive_removal_requires_a_boolean_and_confirmation(
961 + user_root: Path,
962 +) -> None:
963 + profile_root = user_root / "researcher"
964 + manual = profile_root / "manual.txt"
965 + manual.parent.mkdir(parents=True)
966 + manual.write_text("keep until confirmed", encoding="utf-8")
967 + handler = AgentEditor(None, None) # type: ignore[arg-type]
968 +
969 + malformed = await handler.process(
970 + {
971 + "action": "remove_changes",
972 + "profile_id": "researcher",
973 + "destructive": "false",
974 + },
975 + None, # type: ignore[arg-type]
976 + )
977 + unconfirmed = await handler.process(
978 + {
979 + "action": "remove_changes",
980 + "profile_id": "researcher",
981 + "destructive": True,
982 + },
983 + None, # type: ignore[arg-type]
984 + )
985 +
986 + assert malformed.status_code == 400
987 + assert unconfirmed.status_code == 400
988 + assert manual.read_text(encoding="utf-8") == "keep until confirmed"
989 +
990 + applied = await handler.process(
991 + {
992 + "action": "remove_changes",
993 + "profile_id": "researcher",
994 + "destructive": True,
995 + "confirm": True,
996 + },
997 + None, # type: ignore[arg-type]
998 + )
999 + assert applied["ok"] is True
1000 + assert not manual.exists()
1001 +
1002 +
1003 +@pytest.mark.asyncio
1004 +async def test_running_custom_profile_cannot_be_deleted(
1005 + user_root: Path,
1006 + monkeypatch: pytest.MonkeyPatch,
1007 +) -> None:
1008 + profile_root = user_root / "running-custom"
1009 + profile_root.mkdir(parents=True)
1010 + (profile_root / "agent.yaml").write_text(
1011 + "title: Running custom\n",
1012 + encoding="utf-8",
1013 + )
1014 + running = SimpleNamespace(
1015 + config=SimpleNamespace(profile="running-custom"),
1016 + is_running=lambda: True,
1017 + )
1018 + monkeypatch.setattr(
1019 + "plugins._agent_editor.api.agent_editor.AgentContext.all",
1020 + lambda: [running],
1021 + )
1022 +
1023 + response = await AgentEditor(None, None).process( # type: ignore[arg-type]
1024 + {
1025 + "action": "delete",
1026 + "profile_id": "running-custom",
1027 + "confirm": True,
1028 + },
1029 + None, # type: ignore[arg-type]
1030 + )
1031 +
1032 + assert response.status_code == 400
1033 + assert "running" in response.get_data(as_text=True)
1034 + assert profile_root.is_dir()
1035 +
1036 +
1037 +def test_stale_create_plan_cannot_overwrite_a_new_profile(user_root: Path) -> None:
1038 + patch = {
1039 + "profile_id": "create-race",
1040 + "creating": True,
1041 + "editor_mode": "easy",
1042 + "metadata": {"set": {"title": "Create race"}, "reset": []},
1043 + "prompts": {
1044 + "set": {editor.SPECIFICS_FILE: "First writer wins."},
1045 + "reset": [],
1046 + },
1047 + }
1048 + first = editor.build_change_plan(patch)
1049 + stale = editor.build_change_plan(patch)
1050 +
1051 + editor.apply_change_plan(first)
1052 + with pytest.raises(ValueError, match="created before this save completed"):
1053 + editor.apply_change_plan(stale)
1054 +
1055 + assert yaml_helper.loads(
1056 + (user_root / "create-race" / "agent.yaml").read_text(encoding="utf-8")
1057 + ) == {"title": "Create race"}
1058 +
1059 +
1060 +def test_settings_default_profile_catalog_uses_global_availability(
1061 + monkeypatch: pytest.MonkeyPatch,
1062 +) -> None:
1063 + from helpers import settings
1064 +
1065 + monkeypatch.setattr(
1066 + settings.subagents,
1067 + "get_available_agents_dict",
1068 + lambda _project: {
1069 + "default": settings.subagents.SubAgentListItem(
1070 + name="default", title="Default"
1071 + )
1072 + },
1073 + )
1074 + configured = settings.get_default_settings().copy()
1075 + configured["agent_profile"] = "disabled-profile"
1076 +
1077 + options = settings.convert_out(configured)["additional"]["agent_subdirs"]
1078 +
1079 + assert options == [
1080 + {"value": "default", "label": "Default"},
1081 + {
1082 + "value": "disabled-profile",
1083 + "label": "disabled-profile (unavailable)",
1084 + },
1085 + ]
1086 +
1087 +
1088 @pytest.mark.parametrize(
1089 ("relative_path", "patch", "label"),
1090 (
tests/test_projects.py
+73
@@ -107,6 +107,79 @@ def test_project_agent_availability_retains_project_only_profiles(
107 ) == {"project-only": {"enabled": False}}
108
109
110 +def test_project_profile_toggle_preserves_other_entries_and_refuses_bad_json(
111 + monkeypatch,
112 + tmp_path: Path,
113 +) -> None:
114 + _prepare_project_tree(monkeypatch, tmp_path)
115 + meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
116 + meta.mkdir(parents=True)
117 + availability = meta / "agents.json"
118 + monkeypatch.setattr(
119 + subagents,
120 + "get_agents_dict",
121 + lambda _project=None: {
122 + "default": subagents.SubAgentListItem(name="default", enabled=True),
123 + "researcher": subagents.SubAgentListItem(
124 + name="researcher", enabled=True
125 + ),
126 + },
127 + )
128 + availability.write_text(
129 + '{"default":{"enabled":false}}',
130 + encoding="utf-8",
131 + )
132 +
133 + projects.set_project_subagent_enabled("demo", "researcher", False)
134 +
135 + assert dirty_json.parse(availability.read_text(encoding="utf-8")) == {
136 + "default": {"enabled": False},
137 + "researcher": {"enabled": False},
138 + }
139 + broken = b'{"default":'
140 + availability.write_bytes(broken)
141 +
142 + with pytest.raises(ValueError, match="Project agent availability"):
143 + projects.set_project_subagent_enabled("demo", "researcher", True)
144 +
145 + assert availability.read_bytes() == broken
146 +
147 +
148 +def test_project_edit_ignores_stale_agent_availability(
149 + monkeypatch,
150 + tmp_path: Path,
151 +) -> None:
152 + _prepare_project_tree(monkeypatch, tmp_path)
153 + meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
154 + meta.mkdir(parents=True)
155 + (meta / "project.json").write_text('{"title":"Demo"}', encoding="utf-8")
156 + availability = meta / "agents.json"
157 + original = b'{"default":{"enabled":false}}'
158 + availability.write_bytes(original)
159 + monkeypatch.setattr("helpers.git.get_repo_status", lambda _path: {})
160 + monkeypatch.setattr(projects, "reactivate_project_in_chats", lambda _name: None)
161 + extended: list[dict] = []
162 + monkeypatch.setattr(
163 + projects,
164 + "save_project_extended_data",
165 + lambda _name, data: extended.append(data),
166 + )
167 +
168 + loaded = projects.load_edit_project_data("demo")
169 + projects.update_project(
170 + "demo",
171 + {
172 + **loaded,
173 + "title": "Renamed",
174 + "subagents": {"default": {"enabled": True}},
175 + },
176 + )
177 +
178 + assert "subagents" not in loaded
179 + assert availability.read_bytes() == original
180 + assert extended and all("subagents" not in data for data in extended)
181 +
182 +
183 def test_profile_reconciliation_uses_an_available_fallback(monkeypatch) -> None:
184 context_id = "ctx-profile-availability-fallback"
185 AgentContext.remove(context_id)
webui/components/projects/AGENTS.md
+3
@@ -17,6 +17,9 @@
17 - Do not expose project secrets in logs, URLs, or long-lived frontend state unnecessarily.
18 - Preserve scoped settings interactions with plugins, models, skills, and MCP servers.
19 - Project model settings select a global `_model_config` preset; they do not own copied model dictionaries or project-local preset definitions.
20 +- Agent Editor owns profile-scoped availability mutations. Project edit payloads
21 + do not include that state, so unrelated project saves cannot restore stale
22 + toggle values.
23
24 ## Work Guidance
25