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