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.
This commit is contained in:
Alessandro 2026-08-10 05:08:35 +02:00
parent 9e5fd188e2
commit 7545eef298
11 changed files with 386 additions and 55 deletions

View file

@ -64,7 +64,6 @@ class EditProjectData(BasicProjectData):
variables: str
secrets: str
mcp_servers: str
subagents: dict[str, SubAgentSettings]
git_status: GitStatusData
@ -72,7 +71,7 @@ ProjectExtendedData = dict[str, object]
_PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset(
EditProjectData.__annotations__
)
_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"})
_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token", "subagents"})
def get_projects_parent_folder():
@ -227,7 +226,6 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData:
"file_structure",
_default_file_structure_settings(),
),
"subagents": data.get("subagents", {}),
}
return normalized
@ -246,7 +244,6 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData:
"knowledge_files_count": 0,
"variables": "",
"secrets": "",
"subagents": {},
"git_status": {"is_git_repo": False},
},
)
@ -269,7 +266,6 @@ def update_project(name: str, data: EditProjectData):
save_project_variables(name, current["variables"])
save_project_secrets(name, current["secrets"])
save_project_mcp_servers(name, current["mcp_servers"])
save_project_subagents(name, current["subagents"])
save_project_extended_data(name, extended_data)
reactivate_project_in_chats(name)
@ -291,7 +287,6 @@ def load_edit_project_data(name: str) -> EditProjectData:
variables = load_project_variables(name)
mcp_servers = load_project_mcp_servers(name)
secrets = load_project_secrets_masked(name)
subagents = load_project_subagents(name)
knowledge_files_count = get_knowledge_files_count(name)
git_status = cast(GitStatusData, git.get_repo_status(get_project_folder(name)))
@ -305,7 +300,6 @@ def load_edit_project_data(name: str) -> EditProjectData:
"variables": variables,
"mcp_servers": mcp_servers,
"secrets": secrets,
"subagents": subagents,
"git_status": git_status,
},
)
@ -709,6 +703,40 @@ def save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings
files.write_file(abs_path, content)
def set_project_subagent_enabled(name: str, profile_id: str, enabled: bool) -> None:
from helpers import subagents
name = validate_project_name(name)
if not os.path.isdir(get_project_folder(name)):
raise ValueError("Project not found.")
if not isinstance(enabled, bool):
raise ValueError("Agent availability must be true or false.")
agent = subagents.get_agents_dict(name).get(profile_id)
if not agent:
raise ValueError(f'Agent profile "{profile_id}" does not exist.')
path = get_project_meta(name, "agents.json")
try:
settings = dirty_json.parse(files.read_file(path))
except FileNotFoundError:
settings = {}
except Exception as exc:
raise ValueError("Project agent availability is invalid.") from exc
if not isinstance(settings, dict) or any(
not isinstance(key, str)
or not isinstance(value, dict)
or not isinstance(value.get("enabled"), bool)
for key, value in settings.items()
):
raise ValueError("Project agent availability is invalid.")
if agent.enabled == enabled:
settings.pop(profile_id, None)
else:
settings[profile_id] = {"enabled": enabled}
save_project_subagents(name, settings)
def _normalize_subagents(
subagents_data: dict[str, SubAgentSettings], project_name: str = ""
) -> dict[str, SubAgentSettings]:

View file

@ -58,6 +58,7 @@
- `_normalize_include_agents_md(value: object) -> bool`
- `load_project_subagents(name: str) -> dict[str, SubAgentSettings]`
- `save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings])`
- `set_project_subagent_enabled(name: str, profile_id: str, enabled: bool) -> None`
- `_normalize_subagents(subagents_data: dict[str, SubAgentSettings], project_name: str=...) -> dict[str, SubAgentSettings]`
- 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`.
@ -79,7 +80,11 @@
configured default profile, then `agent0`, then the first available profile.
- Per-project profile availability is persisted sparsely in `.a0proj/agents.json`;
entries matching the profile definition's scoped default are omitted. The
helper retains the established plain load/save contract for project settings.
helper retains the established tolerant load contract for read-only settings.
Profile-scoped mutations re-read the file strictly, preserve unrelated
entries, refuse malformed data, and write through `helpers.files`. General
project edit payloads neither expose nor mutate profile availability; legacy
`subagents` input is ignored.
- Profile reconciliation treats `None` as the Global scope. Callers must pass
`all_scopes=True` to check every loaded chat after a Global availability
change. Each pass resolves the available profile catalog once per encountered

View file

@ -258,9 +258,12 @@ def convert_out(settings: Settings) -> SettingsOutput:
chat_providers=get_providers("chat"),
embedding_providers=get_providers("embedding"),
is_dockerized=runtime.is_dockerized(),
agent_subdirs=[{"value": item["key"], "label": item["label"]}
for item in subagents.get_all_agents_list()
if item["key"] != "_example"],
agent_subdirs=[
{"value": key, "label": item.title or key}
for key, item in sorted(
subagents.get_available_agents_dict(None).items()
)
],
knowledge_subdirs=[{"value": subdir, "label": subdir}
for subdir in files.get_subdirectories("knowledge", exclude="default")],
timezones=_timezone_options(),
@ -284,7 +287,14 @@ def convert_out(settings: Settings) -> SettingsOutput:
),
}
additional["agent_subdirs"] = _ensure_option_present(additional.get("agent_subdirs"), current.get("agent_profile"))
current_profile = current.get("agent_profile")
if current_profile and not any(
option["value"] == current_profile
for option in additional["agent_subdirs"]
):
additional["agent_subdirs"].append(
{"value": current_profile, "label": f"{current_profile} (unavailable)"}
)
additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir"))
if current.get("timezone") != TIMEZONE_AUTO:
additional["timezones"] = _ensure_option_present(additional.get("timezones"), current.get("timezone"))

View file

@ -67,6 +67,9 @@
- Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change.
- `max_consecutive_unusable_responses` defaults to `5` and controls the cost circuit breaker for malformed or repeated main-model outputs.
- `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.
- The Global default-profile selector lists only globally available profiles.
A currently configured unavailable profile remains visible with an explicit
unavailable label so settings can round-trip it truthfully.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -26,6 +26,11 @@
- Every profile, including `Default`, can be made unavailable. The backend
rejects only the change that would leave the selected scope with no available
profile, then reconciles loaded chats through the shared project owner.
- Destructive cleanup and custom-profile deletion require an explicit confirmed
apply request. Profile creation and availability invariants are rechecked at
the existing editor mutation boundary; project availability refuses malformed
`agents.json` and changes only the requested profile entry through the existing
project storage owner.
- Never call `helpers.subagents.save_agent_data`.
- Authored profile definitions remain YAML; editor-written plugin configs remain
JSON.
@ -39,6 +44,8 @@
- Advanced prompt text is directly editable; per-file close/check actions
discard or accept the current edit checkpoint, while the editor's global save
remains the only persistence boundary.
- New profiles require a display name and non-empty agent instructions in both
Easy and Advanced; existing Advanced prompt edits retain per-file semantics.
- The configurable tool catalog is visible in both modes; Easy provides direct
allow/block checkboxes and points to Advanced for skill access. Skills remain
Advanced-only. Advanced keeps both complete selectors visible but disabled

View file

@ -16,4 +16,4 @@ and can be removed without changing it.
Manage agents can duplicate the effective profile into the selected scope and
toggle whether each profile is available there. Project availability reuses
`.a0proj/agents.json`; Global availability is a sparse profile override.
The Default profile always remains available.
The selected scope must always keep at least one profile available.

View file

@ -41,20 +41,7 @@ class AgentEditor(ApiHandler):
if not isinstance(enabled, bool):
raise ValueError("Agent availability must be true or false.")
if not enabled:
project_name = editor._context_project_name(context)
for live_context in AgentContext.all():
if (
getattr(live_context.config, "profile", "") == profile_id
and live_context.is_running()
and (
not project_name
or projects.get_context_project_name(live_context)
== project_name
)
):
raise ValueError(
"This agent is running. Disable it after the run finishes."
)
_reject_running_profile(profile_id, context, "Disable")
receipt = editor.set_profile_enabled(profile_id, enabled, context)
return {
"ok": True,
@ -74,10 +61,21 @@ class AgentEditor(ApiHandler):
}
if action in {"plan_remove_changes", "remove_changes"}:
profile_id = editor.validate_profile_id(input.get("profile_id"))
destructive = input.get("destructive", False)
if not isinstance(destructive, bool):
raise ValueError("Destructive removal must be true or false.")
if (
action == "remove_changes"
and destructive
and input.get("confirm") is not True
):
raise ValueError(
"Deleting all profile customizations requires confirmation."
)
plan = editor.plan_remove_changes(
profile_id,
context,
destructive=bool(input.get("destructive")),
destructive=destructive,
)
if action == "plan_remove_changes":
return {"ok": True, **plan.response()}
@ -89,6 +87,12 @@ class AgentEditor(ApiHandler):
}
if action in {"plan_delete", "delete"}:
profile_id = editor.validate_profile_id(input.get("profile_id"))
if action == "delete":
if input.get("confirm") is not True:
raise ValueError(
"Deleting a custom agent requires confirmation."
)
_reject_running_profile(profile_id, context, "Delete")
plan = editor.plan_delete_custom(profile_id, context)
if action == "plan_delete":
return {
@ -96,8 +100,6 @@ class AgentEditor(ApiHandler):
**plan.response(),
"impact": editor.delete_impact(profile_id, context),
}
if input.get("confirm") is not True:
raise ValueError("Deleting a custom agent requires confirmation.")
receipt = editor.apply_change_plan(plan)
project_name = editor._context_project_name(context)
projects.reconcile_agent_profiles(
@ -115,15 +117,38 @@ def _context(input: dict[str, Any]) -> Any:
context = AgentContext.get(context_id)
if not context:
raise ValueError("Chat context not found.")
_validate_project_scope(projects.get_context_project_name(context))
return context
project_name = str(input.get("project_name") or "").strip()
if project_name:
project_name = projects.validate_project_name(project_name)
if not Path(projects.get_project_folder(project_name)).is_dir():
raise ValueError("Project not found.")
project_name = _validate_project_scope(input.get("project_name"))
return editor._EditorContext(project_name)
def _validate_project_scope(project_name: Any) -> str:
value = str(project_name or "").strip()
if not value:
return ""
value = projects.validate_project_name(value)
if not Path(projects.get_project_folder(value)).is_dir():
raise ValueError("Project not found.")
return value
def _reject_running_profile(profile_id: str, context: Any, action: str) -> None:
project_name = editor._context_project_name(context)
for live_context in AgentContext.all():
if (
getattr(live_context.config, "profile", "") == profile_id
and live_context.is_running()
and (
not project_name
or projects.get_context_project_name(live_context) == project_name
)
):
raise ValueError(
f"This agent is running. {action} it after the run finishes."
)
def _active_profile(input: dict[str, Any]) -> dict[str, str]:
context_id = str(input.get("active_context_id") or "").strip()
context = AgentContext.get(context_id) if context_id else None

View file

@ -82,6 +82,7 @@ class ChangePlan:
staged_tokens: set[str] = field(default_factory=set)
profile_id: str = ""
project_name: str = ""
creating: bool = False
remove_empty_root: bool = False
def write(self, path: Path, content: str | bytes) -> None:
@ -681,7 +682,11 @@ def build_change_plan(
if not creating and not exists:
raise ValueError(f'Agent profile "{profile_id}" does not exist.')
plan = ChangePlan(profile_id=profile_id, project_name=project_name)
plan = ChangePlan(
profile_id=profile_id,
project_name=project_name,
creating=creating,
)
if "metadata" in patch:
_plan_metadata(plan, patch["metadata"], context, creating=creating)
if "prompts" in patch:
@ -758,25 +763,26 @@ def set_profile_enabled(
if not profile_exists(profile_id, context):
raise ValueError(f'Agent profile "{profile_id}" does not exist.')
available = subagents.get_available_agents_dict(project_name or None)
if not enabled and profile_id in available and len(available) == 1:
raise ValueError("At least one agent profile must remain available.")
with _MUTATION_LOCK:
available = subagents.get_available_agents_dict(project_name or None)
if not enabled and profile_id in available and len(available) == 1:
raise ValueError("At least one agent profile must remain available.")
if project_name:
settings = projects.load_project_subagents(project_name)
settings[profile_id] = {"enabled": enabled}
projects.save_project_subagents(project_name, settings)
receipt = {
"written": [
_relative_source(
Path(projects.get_project_meta(project_name, "agents.json"))
)
],
"deleted": [],
"warnings": [],
}
else:
receipt = apply_change_plan(plan_profile_enabled(profile_id, enabled, context))
if project_name:
projects.set_project_subagent_enabled(project_name, profile_id, enabled)
receipt = {
"written": [
_relative_source(
Path(projects.get_project_meta(project_name, "agents.json"))
)
],
"deleted": [],
"warnings": [],
}
else:
receipt = apply_change_plan(
plan_profile_enabled(profile_id, enabled, context)
)
if not enabled:
projects.reconcile_agent_profiles(
@ -807,7 +813,11 @@ def plan_duplicate_profile(
target_title = f"{source_title} {index}"
project_name = _context_project_name(context)
target_root = _profile_root(target_id, project_name)
plan = ChangePlan(profile_id=target_id, project_name=project_name)
plan = ChangePlan(
profile_id=target_id,
project_name=project_name,
creating=True,
)
metadata: dict[str, Any] = {}
for layer in _metadata_layers(profile_id, context):
@ -1128,6 +1138,13 @@ def apply_change_plan(plan: ChangePlan) -> dict[str, Any]:
receipt = plan.response()
with _MUTATION_LOCK:
if plan.creating and profile_exists(
plan.profile_id,
_EditorContext(project_name),
):
raise ValueError(
f'Agent profile "{plan.profile_id}" was created before this save completed.'
)
snapshots = {
change.path: change.path.read_bytes() if change.path.is_file() else None
for change in changes
@ -1293,6 +1310,8 @@ def stage_avatar(upload: Any) -> dict[str, Any]:
raise ValueError("Avatar must be a valid PNG, JPEG, or WebP image.") from exc
except Image.DecompressionBombError as exc:
raise ValueError("Avatar dimensions are too large.") from exc
except OSError as exc:
raise ValueError("Avatar must be a valid PNG, JPEG, or WebP image.") from exc
_cleanup_staged_avatars()
STAGED_AVATAR_ROOT.mkdir(parents=True, exist_ok=True)

View file

@ -4,6 +4,7 @@ from io import BytesIO
import json
from pathlib import Path
import stat
from types import SimpleNamespace
import pytest
from werkzeug.datastructures import FileStorage
@ -480,11 +481,20 @@ def test_project_customizations_inherit_global_agent_and_remove_only_project_fil
def test_project_scope_is_validated_at_api_and_apply_boundaries(
user_root: Path,
project_scope: tuple[editor._EditorContext, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
context, _project_agents = project_scope
assert editor.projects.get_context_project_name(editor_context({"project_name": "demo"})) == "demo"
with pytest.raises(ValueError, match="Project not found"):
editor_context({"project_name": "missing-agent-editor-project"})
monkeypatch.setattr(
"plugins._agent_editor.api.agent_editor.AgentContext.get",
lambda _context_id: SimpleNamespace(
get_data=lambda _key, recursive=True: "../outside"
),
)
with pytest.raises(ValueError, match="Invalid project name"):
editor_context({"context_id": "unsafe-project-context"})
forged = editor.ChangePlan(profile_id="researcher", project_name="demo")
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) ->
assert not normalized.getexif()
@pytest.mark.asyncio
async def test_truncated_avatar_is_a_validation_error(user_root: Path) -> None:
from PIL import Image
source = BytesIO()
Image.new("RGB", (32, 32), "red").save(source, format="PNG")
upload = FileStorage(
stream=BytesIO(source.getvalue()[:-24]),
filename="truncated.png",
)
response = await AgentEditorAvatar(None, None).process( # type: ignore[arg-type]
{},
SimpleNamespace(method="POST", files={"avatar": upload}),
)
assert response.status_code == 400
assert "valid PNG, JPEG, or WebP" in response.get_data(as_text=True)
@pytest.mark.asyncio
async def test_destructive_removal_requires_a_boolean_and_confirmation(
user_root: Path,
) -> None:
profile_root = user_root / "researcher"
manual = profile_root / "manual.txt"
manual.parent.mkdir(parents=True)
manual.write_text("keep until confirmed", encoding="utf-8")
handler = AgentEditor(None, None) # type: ignore[arg-type]
malformed = await handler.process(
{
"action": "remove_changes",
"profile_id": "researcher",
"destructive": "false",
},
None, # type: ignore[arg-type]
)
unconfirmed = await handler.process(
{
"action": "remove_changes",
"profile_id": "researcher",
"destructive": True,
},
None, # type: ignore[arg-type]
)
assert malformed.status_code == 400
assert unconfirmed.status_code == 400
assert manual.read_text(encoding="utf-8") == "keep until confirmed"
applied = await handler.process(
{
"action": "remove_changes",
"profile_id": "researcher",
"destructive": True,
"confirm": True,
},
None, # type: ignore[arg-type]
)
assert applied["ok"] is True
assert not manual.exists()
@pytest.mark.asyncio
async def test_running_custom_profile_cannot_be_deleted(
user_root: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile_root = user_root / "running-custom"
profile_root.mkdir(parents=True)
(profile_root / "agent.yaml").write_text(
"title: Running custom\n",
encoding="utf-8",
)
running = SimpleNamespace(
config=SimpleNamespace(profile="running-custom"),
is_running=lambda: True,
)
monkeypatch.setattr(
"plugins._agent_editor.api.agent_editor.AgentContext.all",
lambda: [running],
)
response = await AgentEditor(None, None).process( # type: ignore[arg-type]
{
"action": "delete",
"profile_id": "running-custom",
"confirm": True,
},
None, # type: ignore[arg-type]
)
assert response.status_code == 400
assert "running" in response.get_data(as_text=True)
assert profile_root.is_dir()
def test_stale_create_plan_cannot_overwrite_a_new_profile(user_root: Path) -> None:
patch = {
"profile_id": "create-race",
"creating": True,
"editor_mode": "easy",
"metadata": {"set": {"title": "Create race"}, "reset": []},
"prompts": {
"set": {editor.SPECIFICS_FILE: "First writer wins."},
"reset": [],
},
}
first = editor.build_change_plan(patch)
stale = editor.build_change_plan(patch)
editor.apply_change_plan(first)
with pytest.raises(ValueError, match="created before this save completed"):
editor.apply_change_plan(stale)
assert yaml_helper.loads(
(user_root / "create-race" / "agent.yaml").read_text(encoding="utf-8")
) == {"title": "Create race"}
def test_settings_default_profile_catalog_uses_global_availability(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from helpers import settings
monkeypatch.setattr(
settings.subagents,
"get_available_agents_dict",
lambda _project: {
"default": settings.subagents.SubAgentListItem(
name="default", title="Default"
)
},
)
configured = settings.get_default_settings().copy()
configured["agent_profile"] = "disabled-profile"
options = settings.convert_out(configured)["additional"]["agent_subdirs"]
assert options == [
{"value": "default", "label": "Default"},
{
"value": "disabled-profile",
"label": "disabled-profile (unavailable)",
},
]
@pytest.mark.parametrize(
("relative_path", "patch", "label"),
(

View file

@ -107,6 +107,79 @@ def test_project_agent_availability_retains_project_only_profiles(
) == {"project-only": {"enabled": False}}
def test_project_profile_toggle_preserves_other_entries_and_refuses_bad_json(
monkeypatch,
tmp_path: Path,
) -> None:
_prepare_project_tree(monkeypatch, tmp_path)
meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
meta.mkdir(parents=True)
availability = meta / "agents.json"
monkeypatch.setattr(
subagents,
"get_agents_dict",
lambda _project=None: {
"default": subagents.SubAgentListItem(name="default", enabled=True),
"researcher": subagents.SubAgentListItem(
name="researcher", enabled=True
),
},
)
availability.write_text(
'{"default":{"enabled":false}}',
encoding="utf-8",
)
projects.set_project_subagent_enabled("demo", "researcher", False)
assert dirty_json.parse(availability.read_text(encoding="utf-8")) == {
"default": {"enabled": False},
"researcher": {"enabled": False},
}
broken = b'{"default":'
availability.write_bytes(broken)
with pytest.raises(ValueError, match="Project agent availability"):
projects.set_project_subagent_enabled("demo", "researcher", True)
assert availability.read_bytes() == broken
def test_project_edit_ignores_stale_agent_availability(
monkeypatch,
tmp_path: Path,
) -> None:
_prepare_project_tree(monkeypatch, tmp_path)
meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
meta.mkdir(parents=True)
(meta / "project.json").write_text('{"title":"Demo"}', encoding="utf-8")
availability = meta / "agents.json"
original = b'{"default":{"enabled":false}}'
availability.write_bytes(original)
monkeypatch.setattr("helpers.git.get_repo_status", lambda _path: {})
monkeypatch.setattr(projects, "reactivate_project_in_chats", lambda _name: None)
extended: list[dict] = []
monkeypatch.setattr(
projects,
"save_project_extended_data",
lambda _name, data: extended.append(data),
)
loaded = projects.load_edit_project_data("demo")
projects.update_project(
"demo",
{
**loaded,
"title": "Renamed",
"subagents": {"default": {"enabled": True}},
},
)
assert "subagents" not in loaded
assert availability.read_bytes() == original
assert extended and all("subagents" not in data for data in extended)
def test_profile_reconciliation_uses_an_available_fallback(monkeypatch) -> None:
context_id = "ctx-profile-availability-fallback"
AgentContext.remove(context_id)

View file

@ -17,6 +17,9 @@
- Do not expose project secrets in logs, URLs, or long-lived frontend state unnecessarily.
- Preserve scoped settings interactions with plugins, models, skills, and MCP servers.
- Project model settings select a global `_model_config` preset; they do not own copied model dictionaries or project-local preset definitions.
- Agent Editor owns profile-scoped availability mutations. Project edit payloads
do not include that state, so unrelated project saves cannot restore stale
toggle values.
## Work Guidance