Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion backend/app/gateway/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ async def get_thread(thread_id: str, request: Request):
- runs:create - Run agent
- runs:read - View run
- runs:cancel - Cancel run
- memory:read - View memory data/config
- memory:write - Modify memory data (create/update/delete facts, import, clear)
- agents:read - View custom agents and the user profile
- agents:write - Create/update/delete custom agents and the user profile
"""

from __future__ import annotations
Expand Down Expand Up @@ -66,6 +70,14 @@ class Permissions:
RUNS_READ = "runs:read"
RUNS_CANCEL = "runs:cancel"

# Memory (per-user memory data surfaced by /api/memory*)
MEMORY_READ = "memory:read"
MEMORY_WRITE = "memory:write"

# Custom agents and the per-user USER.md profile (/api/agents*, /api/user-profile)
AGENTS_READ = "agents:read"
AGENTS_WRITE = "agents:write"


class AuthContext:
"""Authentication context for the current request.
Expand Down Expand Up @@ -124,6 +136,10 @@ def get_auth_context(request: Request) -> AuthContext | None:
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
]


Expand Down Expand Up @@ -451,12 +467,22 @@ async def delete_thread(thread_id: str, request: Request):
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
# Bind the wrapped signature so a request passed positionally
# (direct calls in unit tests, non-FastAPI callers) is honored
# instead of colliding with an injected keyword stub.
signature = inspect.signature(func)
try:
bound = signature.bind(*args, **kwargs)
except TypeError:
bound = None
request = kwargs.get("request")
if request is None and bound is not None:
request = bound.arguments.get("request")
if request is None:
# Unit tests may call decorated route handlers directly without
# constructing a FastAPI Request object. Inject a minimal stub
# when the wrapped function declares `request`.
if "request" in inspect.signature(func).parameters:
if "request" in signature.parameters:
kwargs["request"] = _make_test_request_stub()
else:
return await func(*args, **kwargs)
Expand Down Expand Up @@ -493,6 +519,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE

thread_id = kwargs.get("thread_id")
if thread_id is None and bound is not None:
thread_id = bound.arguments.get("thread_id")
if thread_id is None:
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")

Expand Down
114 changes: 67 additions & 47 deletions backend/app/gateway/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import re
from typing import Literal

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field

from app.gateway.authz import require_permission
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import (
AgentConfig,
Expand Down Expand Up @@ -206,7 +207,8 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
summary="List Custom Agents",
description="List all custom agents available in the agents directory, including their soul content.",
)
async def list_agents() -> AgentsListResponse:
@require_permission("agents", "read")
async def list_agents(request: Request) -> AgentsListResponse:
"""List all custom agents.

Returns:
Expand Down Expand Up @@ -235,7 +237,8 @@ def _list() -> AgentsListResponse:
summary="Check Agent Name",
description="Validate an agent name and check if it is available (case-insensitive).",
)
async def check_agent_name(name: str) -> dict:
@require_permission("agents", "read")
async def check_agent_name(name: str, request: Request) -> dict:
"""Check whether an agent name is valid and not yet taken.

Args:
Expand Down Expand Up @@ -264,7 +267,8 @@ async def check_agent_name(name: str) -> dict:
summary="Get Custom Agent",
description="Retrieve details and SOUL.md content for a specific custom agent.",
)
async def get_agent(name: str) -> AgentResponse:
@require_permission("agents", "read")
async def get_agent(name: str, request: Request) -> AgentResponse:
"""Get a specific custom agent by name.

Args:
Expand Down Expand Up @@ -302,11 +306,13 @@ def _get() -> AgentResponse:
summary="Create Custom Agent",
description="Create a new custom agent with its config and SOUL.md.",
)
async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
@require_permission("agents", "write")
async def create_agent_endpoint(body: AgentCreateRequest, request: Request) -> AgentResponse:
"""Create a new custom agent.

Args:
request: The agent creation request.
body: The agent creation request.
request: The FastAPI request (used by the permission decorator).

Returns:
The created agent details.
Expand All @@ -315,31 +321,31 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
HTTPException: 409 if agent already exists, 422 if name is invalid.
"""
_require_agents_api_enabled()
_validate_agent_name(request.name)
_validate_model_exists(request.model)
normalized_name = _normalize_agent_name(request.name)
_validate_agent_name(body.name)
_validate_model_exists(body.model)
normalized_name = _normalize_agent_name(body.name)
user_id = get_effective_user_id()

# Config document — only the fields the caller set, matching the historical
# writer (an omitted field stays absent rather than being materialized).
config_data: dict = {"name": normalized_name}
if request.description:
config_data["description"] = request.description
if request.tool_groups is not None:
config_data["tool_groups"] = request.tool_groups
if request.skills is not None:
config_data["skills"] = request.skills
if request.allowed_subagents is not None:
config_data["allowed_subagents"] = request.allowed_subagents
if body.description:
config_data["description"] = body.description
if body.tool_groups is not None:
config_data["tool_groups"] = body.tool_groups
if body.skills is not None:
config_data["skills"] = body.skills
if body.allowed_subagents is not None:
config_data["allowed_subagents"] = body.allowed_subagents
# model / model_settings / thinking_enabled / reasoning_effort (issue #4336).
_apply_model_behavior(config_data, request)
_apply_model_behavior(config_data, body)

store = get_agent_store()

def _create_agent() -> AgentResponse:
# Worker thread: existence checks + persistence (file IO or a DB round
# trip) must stay off the event loop.
store.create(normalized_name, config_data, request.soul, user_id=user_id)
store.create(normalized_name, config_data, body.soul, user_id=user_id)
logger.info("Created agent '%s'", normalized_name)
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
Expand All @@ -349,7 +355,7 @@ def _create_agent() -> AgentResponse:
except AgentExistsError:
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
except Exception as e:
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
logger.error(f"Failed to create agent '{body.name}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")


Expand All @@ -359,12 +365,14 @@ def _create_agent() -> AgentResponse:
summary="Update Custom Agent",
description="Update an existing custom agent's config and/or SOUL.md.",
)
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
@require_permission("agents", "write")
async def update_agent(name: str, body: AgentUpdateRequest, request: Request) -> AgentResponse:
"""Update an existing custom agent.

Args:
name: The agent name.
request: The update request (all fields optional).
body: The update request (all fields optional).
request: The FastAPI request (used by the permission decorator).

Returns:
The updated agent details.
Expand Down Expand Up @@ -403,44 +411,44 @@ def _is_legacy_only_layout() -> bool:
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating."),
)

if "model" in request.model_fields_set:
_validate_model_exists(request.model)
if "model" in body.model_fields_set:
_validate_model_exists(body.model)

try:
# Update config if any config fields changed
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
# This is critical for skills where None means "inherit all" (not "don't change").
fields_set = request.model_fields_set
fields_set = body.model_fields_set
config_changed = bool(fields_set & ({"description", "tool_groups", "skills", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))

updated: dict | None = None
if config_changed:
updated = {
"name": agent_cfg.name,
"description": request.description if "description" in fields_set else agent_cfg.description,
"description": body.description if "description" in fields_set else agent_cfg.description,
}

new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
new_tool_groups = body.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
if new_tool_groups is not None:
updated["tool_groups"] = new_tool_groups

# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
if "skills" in fields_set:
new_skills = request.skills
new_skills = body.skills
else:
new_skills = agent_cfg.skills
if new_skills is not None:
updated["skills"] = new_skills

# allowed_subagents: None = all, [] = hard deny, list = whitelist.
new_allowed_subagents = request.allowed_subagents if "allowed_subagents" in fields_set else agent_cfg.allowed_subagents
new_allowed_subagents = body.allowed_subagents if "allowed_subagents" in fields_set else agent_cfg.allowed_subagents
if new_allowed_subagents is not None:
updated["allowed_subagents"] = new_allowed_subagents

# model / model_settings / thinking_enabled / reasoning_effort:
# take explicitly-set request fields, else preserve the existing
# value (issue #4336).
_apply_model_behavior(updated, request, existing=agent_cfg)
_apply_model_behavior(updated, body, existing=agent_cfg)

# Carry forward every top-level AgentConfig field this route does
# not manage (currently ``github:``, plus any future field added
Expand All @@ -456,8 +464,8 @@ def _is_legacy_only_layout() -> bool:
store = get_agent_store()
# Persist config (when changed) and/or soul (when provided) off the
# event loop. A no-change PATCH commits nothing and re-reads current state.
if updated is not None or request.soul is not None:
await asyncio.to_thread(store.update, name, updated, request.soul, user_id=user_id)
if updated is not None or body.soul is not None:
await asyncio.to_thread(store.update, name, updated, body.soul, user_id=user_id)

logger.info(f"Updated agent '{name}'")

Expand All @@ -476,13 +484,13 @@ def _refresh() -> AgentResponse:


class UserProfileResponse(BaseModel):
"""Response model for the global user profile (USER.md)."""
"""Response model for the user-scoped profile (USER.md)."""

content: str | None = Field(default=None, description="USER.md content, or null if not yet created")


class UserProfileUpdateRequest(BaseModel):
"""Request body for setting the global user profile."""
"""Request body for setting the user-scoped profile."""

content: str = Field(default="", description="USER.md content — describes the user's background and preferences")

Expand All @@ -491,18 +499,23 @@ class UserProfileUpdateRequest(BaseModel):
"/user-profile",
response_model=UserProfileResponse,
summary="Get User Profile",
description="Read the global USER.md file that is injected into all custom agents.",
description="Read the caller's per-user USER.md file (injected into that user's agents).",
)
async def get_user_profile() -> UserProfileResponse:
"""Return the current USER.md content.
@require_permission("agents", "read")
async def get_user_profile(request: Request) -> UserProfileResponse:
"""Return the current user's USER.md content.

The file is scoped to the caller's user bucket
(``{base_dir}/users/{user_id}/USER.md``), so one user can never read or
write the prompt context of another.

Returns:
UserProfileResponse with content=None if USER.md does not exist yet.
"""
_require_agents_api_enabled()

try:
user_md_path = get_paths().user_md_file
user_md_path = get_paths().user_md_file(get_effective_user_id())
if not user_md_path.exists():
return UserProfileResponse(content=None)
raw = user_md_path.read_text(encoding="utf-8").strip()
Expand All @@ -516,13 +529,18 @@ async def get_user_profile() -> UserProfileResponse:
"/user-profile",
response_model=UserProfileResponse,
summary="Update User Profile",
description="Write the global USER.md file that is injected into all custom agents.",
description="Write the caller's per-user USER.md file (injected into that user's agents).",
)
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
"""Create or overwrite the global USER.md.
@require_permission("agents", "write")
async def update_user_profile(body: UserProfileUpdateRequest, request: Request) -> UserProfileResponse:
"""Create or overwrite the current user's USER.md.

The write targets the caller's own user bucket, so the content only ever
affects that user's agent prompts.

Args:
request: The update request with the new USER.md content.
body: The update request with the new USER.md content.
request: The FastAPI request (used by the permission decorator).

Returns:
UserProfileResponse with the saved content.
Expand All @@ -531,10 +549,11 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR

try:
paths = get_paths()
paths.base_dir.mkdir(parents=True, exist_ok=True)
paths.user_md_file.write_text(request.content, encoding="utf-8")
logger.info(f"Updated USER.md at {paths.user_md_file}")
return UserProfileResponse(content=request.content or None)
user_md_path = paths.user_md_file(get_effective_user_id())
user_md_path.parent.mkdir(parents=True, exist_ok=True)
user_md_path.write_text(body.content, encoding="utf-8")
logger.info(f"Updated USER.md at {user_md_path}")
return UserProfileResponse(content=body.content or None)
except Exception as e:
logger.error(f"Failed to update user profile: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}")
Expand All @@ -546,7 +565,8 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
summary="Delete Custom Agent",
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
)
async def delete_agent(name: str) -> None:
@require_permission("agents", "write")
async def delete_agent(name: str, request: Request) -> None:
"""Delete a custom agent.

Args:
Expand Down
Loading