mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-08-19 05:15:16 +00:00
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:
parent
9e5fd188e2
commit
7545eef298
11 changed files with 386 additions and 55 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue