mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
fix(authz): cover create/run/memory/agent routes with permission checks; scope USER.md per user (#4989)
* fix(authz): cover create/run/memory/agent routes with permission checks; scope USER.md per user
Route permissions: the AuthorizationProvider model only applied to routes
carrying @require_permission, so POST /api/threads, /api/threads/search,
POST /api/runs/stream and /api/runs/wait (runs:create), every /api/memory
route and the custom-agent routes ran with authentication only — a
provider configured to deny threads:write/runs:create/... could not
enforce those decisions. Add the missing decorators (threads:write,
threads:read, runs:create, memory:read/write, agents:read/write) and
register the new permission names in authz.Permissions. No owner checks
are added: per-user data scoping stays in the repository layer.
User profile: GET/PUT /api/user-profile read and wrote a single global
{base_dir}/USER.md, so in a multi-user deployment any authenticated user
could overwrite the prompt context injected for everyone else (and read
it). Scope the file to the caller's bucket
({base_dir}/users/{user_id}/USER.md) like user-scoped skills; no other
consumer of the old global path exists in the tree (verified by grep and
the updated tests). test_put_user_profile asserted the wrong effective
user under the autouse conftest user fixture; fixed to test-user-autouse.
* fix(authz): bind positional args in require_permission; migrate legacy USER.md
Review follow-up on #4989 (willem-bd):
- require_permission's wrapper only looked for request in keyword
arguments, so direct positional calls like
create_thread(body, request) made the injected keyword stub collide
with the positional request (TypeError: multiple values). The wrapper
now binds the wrapped signature via inspect.signature().bind() and
honors a positionally-passed request (and thread_id) instead of
assuming kwargs; the test-stub injection only fires when request is
absent everywhere. The two positional callers in
tests/test_threads_router.py pass again (8/8 channel tests).
- migrate_user_isolation.py now claims the legacy global USER.md for
--user-id (default 'default') like the other unowned legacy
artifacts; without it, upgrading installs with an existing profile
would read content: null and later strand the old file beside the
new per-user one. Conflict handling mirrors migrate_memory (rename to
USER.legacy.md).
* style(scripts): keep migrate_user_isolation help within the line budget
* test(authz): give internal-request stubs realistic auth fields
The create_thread permission wrapper added in this PR authenticates
the direct calls in the internal-owner tests; their SimpleNamespace
requests had state.user but no auth_source and no cookies, so
get_current_user_from_request fell through to request.cookies.get and
raised AttributeError (verified introduced by this branch: both tests
pass on the base commit).
The stubs now carry cookies={} and
state.auth_source=AUTH_SOURCE_INTERNAL, which is exactly what
AuthMiddleware stamps on real internal requests, so state.user is
honored without the JWT path. All 80 tests in the file pass.
* fix(pat): keep memory/agent permissions out of PAT scopes by design
The route permissions exist (they guard the memory/agent routers) but PATs
govern the thread/run lifecycle only: _PAT_ROUTE_RULES default-denies those
routers for PAT callers regardless of scopes. The alignment invariant
becomes a subset check plus a pinned exclusion, and pat.py documents that
opening these scopes is a product decision requiring three synchronized
changes.
* test/docs: cover migrate_user_profile; drop the unclaimed USER.md injection wording
Five-scenario test class mirroring the sibling migration steps (move,
conflict-rename, noop, both under dry-run). The GET/PUT descriptions no
longer say USER.md is 'injected into agents' — nothing at this head consumes
it for prompts; the routes are storage/retrieval only. The memory AGENTS.md
migration pointer now enumerates skills/ and the global USER.md.
* docs(paths): align the USER.md layout comment with the injection disclaimer
* test(authz): align effective permission contracts
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
4fe64ac916
commit
1b2898c367
@ -39,6 +39,14 @@ PAT_ALLOWED_SCOPES: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Deliberately NOT in PAT_ALLOWED_SCOPES: the route permissions "memory:read",
|
||||
# "memory:write", "agents:read", and "agents:write" exist in authz
|
||||
# (they guard the memory/agent routers), but PATs stay on the thread/run
|
||||
# lifecycle only — _PAT_ROUTE_RULES default-denies the memory/agent routes
|
||||
# for PAT callers regardless of scopes, so admitting these scopes would not
|
||||
# even reach those routes. Opening them to PATs is a product decision that
|
||||
# must change all three of: this set, _PAT_ROUTE_RULES, and the API docs.
|
||||
|
||||
PAT_MAX_NAME_LENGTH = 128
|
||||
|
||||
# Default-deny route boundary for PAT callers (#5041 review P1-1): scope
|
||||
|
||||
@ -25,6 +25,10 @@ Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blo
|
||||
- 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
|
||||
@ -73,6 +77,14 @@ class Permissions:
|
||||
PROJECTS_WRITE = "projects:write"
|
||||
PROJECTS_DELETE = "projects:delete"
|
||||
|
||||
# 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.
|
||||
@ -153,6 +165,10 @@ _ALL_PERMISSIONS: list[str] = [
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
@ -657,20 +673,25 @@ def require_permission(
|
||||
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 — with
|
||||
# or without constructing a FastAPI Request object — and may
|
||||
# pass ``request`` positionally. Bind to the real signature
|
||||
# first so a positional request is found rather than
|
||||
# duplicated by the stub injection below.
|
||||
try:
|
||||
bound = inspect.signature(func).bind_partial(*args, **kwargs)
|
||||
except TypeError:
|
||||
bound = None
|
||||
if bound is not None and "request" in bound.arguments:
|
||||
request = bound.arguments["request"]
|
||||
elif "request" in inspect.signature(func).parameters:
|
||||
# pass ``request`` positionally. The full-signature bind at
|
||||
# the top of this wrapper already recovered a positional
|
||||
# request, so only the stub injection for handlers that
|
||||
# declare ``request`` but received none remains here.
|
||||
if "request" in signature.parameters:
|
||||
kwargs["request"] = _make_test_request_stub()
|
||||
request = kwargs["request"]
|
||||
else:
|
||||
@ -707,6 +728,8 @@ def require_permission(
|
||||
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")
|
||||
|
||||
|
||||
@ -5,9 +5,10 @@ import logging
|
||||
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.agents.memory.manager import get_memory_manager
|
||||
from deerflow.config.agents_api_config import get_agents_api_config
|
||||
from deerflow.config.agents_config import (
|
||||
@ -221,7 +222,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:
|
||||
@ -250,7 +252,8 @@ async def list_agents() -> 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:
|
||||
@ -283,7 +286,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:
|
||||
@ -321,11 +325,13 @@ async def get_agent(name: str) -> 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.
|
||||
@ -334,36 +340,36 @@ 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.display_name:
|
||||
config_data["display_name"] = request.display_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.knowledge_scope is not None:
|
||||
config_data["knowledge_scope"] = canonicalize_knowledge_scope(request.knowledge_scope)
|
||||
if request.mcp_plugins is not None:
|
||||
config_data["mcp_plugins"] = request.mcp_plugins
|
||||
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.display_name:
|
||||
config_data["display_name"] = body.display_name
|
||||
if body.description:
|
||||
config_data["description"] = body.description
|
||||
if body.tool_groups is not None:
|
||||
config_data["tool_groups"] = body.tool_groups
|
||||
if body.knowledge_scope is not None:
|
||||
config_data["knowledge_scope"] = canonicalize_knowledge_scope(body.knowledge_scope)
|
||||
if body.mcp_plugins is not None:
|
||||
config_data["mcp_plugins"] = body.mcp_plugins
|
||||
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)
|
||||
|
||||
def _create_agent() -> AgentResponse:
|
||||
# Worker thread: existence checks + persistence (file IO or a DB round
|
||||
# trip) must stay off the event loop.
|
||||
store = get_agent_store()
|
||||
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)
|
||||
@ -373,7 +379,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> 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)}")
|
||||
|
||||
|
||||
@ -383,12 +389,14 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> 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.
|
||||
@ -427,52 +435,52 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
||||
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 & ({"display_name", "description", "tool_groups", "skills", "mcp_plugins", "knowledge_scope", "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,
|
||||
}
|
||||
if "display_name" in fields_set:
|
||||
updated["display_name"] = request.display_name or None
|
||||
updated["display_name"] = body.display_name or None
|
||||
|
||||
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
|
||||
|
||||
if "knowledge_scope" in fields_set:
|
||||
updated["knowledge_scope"] = canonicalize_knowledge_scope(request.knowledge_scope) if request.knowledge_scope is not None else None
|
||||
updated["knowledge_scope"] = canonicalize_knowledge_scope(body.knowledge_scope) if body.knowledge_scope is not None else None
|
||||
|
||||
if "mcp_plugins" in fields_set:
|
||||
updated["mcp_plugins"] = request.mcp_plugins
|
||||
updated["mcp_plugins"] = body.mcp_plugins
|
||||
|
||||
# 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
|
||||
@ -487,10 +495,10 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
||||
|
||||
# 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:
|
||||
if updated is not None or body.soul is not None:
|
||||
|
||||
def _update_agent() -> None:
|
||||
get_agent_store().update(name, updated, request.soul, user_id=user_id)
|
||||
get_agent_store().update(name, updated, body.soul, user_id=user_id)
|
||||
|
||||
await asyncio.to_thread(_update_agent)
|
||||
|
||||
@ -511,13 +519,13 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> 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")
|
||||
|
||||
@ -526,10 +534,15 @@ 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.",
|
||||
)
|
||||
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.
|
||||
@ -537,7 +550,7 @@ async def get_user_profile() -> UserProfileResponse:
|
||||
_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()
|
||||
@ -551,13 +564,20 @@ 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.",
|
||||
)
|
||||
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 one user can never
|
||||
write the profile of another. (Storage and retrieval only — nothing at
|
||||
this head consumes USER.md for prompt injection; this route and the GET
|
||||
above are its only readers/writers.)
|
||||
|
||||
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.
|
||||
@ -566,10 +586,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)}")
|
||||
@ -581,7 +602,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:
|
||||
|
||||
@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
|
||||
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
@ -208,7 +209,8 @@ class MemoryStatusResponse(BaseModel):
|
||||
summary="Get Memory Data",
|
||||
description="Retrieve the current global memory data including user context, history, and facts.",
|
||||
)
|
||||
async def get_memory(http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "read")
|
||||
async def get_memory(request: Request) -> MemoryResponse:
|
||||
"""Get the current global memory data.
|
||||
|
||||
Returns:
|
||||
@ -243,7 +245,7 @@ async def get_memory(http_request: Request) -> MemoryResponse:
|
||||
```
|
||||
"""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(http_request), "get memory")
|
||||
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "get memory")
|
||||
return MemoryResponse(**memory_data)
|
||||
|
||||
|
||||
@ -254,7 +256,8 @@ async def get_memory(http_request: Request) -> MemoryResponse:
|
||||
summary="Reload Memory Data",
|
||||
description="Reload memory data from the storage file, refreshing the in-memory cache.",
|
||||
)
|
||||
async def reload_memory(http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "read")
|
||||
async def reload_memory(request: Request) -> MemoryResponse:
|
||||
"""Reload memory data from file.
|
||||
|
||||
This forces a reload of the memory data from the storage file,
|
||||
@ -263,7 +266,7 @@ async def reload_memory(http_request: Request) -> MemoryResponse:
|
||||
Returns:
|
||||
The reloaded memory data.
|
||||
"""
|
||||
user_id = _resolve_memory_user_id(http_request)
|
||||
user_id = _resolve_memory_user_id(request)
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
try:
|
||||
memory_data = await asyncio.to_thread(manager.reload_memory, user_id=user_id)
|
||||
@ -287,11 +290,12 @@ async def reload_memory(http_request: Request) -> MemoryResponse:
|
||||
summary="Clear All Memory Data",
|
||||
description="Delete all saved memory data and reset the memory structure to an empty state.",
|
||||
)
|
||||
async def clear_memory(http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "write")
|
||||
async def clear_memory(request: Request) -> MemoryResponse:
|
||||
"""Clear all persisted memory data."""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
try:
|
||||
memory_data = await asyncio.to_thread(manager.clear_memory, user_id=_resolve_memory_user_id(http_request))
|
||||
memory_data = await asyncio.to_thread(manager.clear_memory, user_id=_resolve_memory_user_id(request))
|
||||
except NotImplementedError:
|
||||
raise _unsupported_501(manager, "clear memory") from None
|
||||
except (MemoryConflictError, MemoryCorruptionError) as exc:
|
||||
@ -309,16 +313,17 @@ async def clear_memory(http_request: Request) -> MemoryResponse:
|
||||
summary="Create Memory Fact",
|
||||
description="Create a single saved memory fact manually.",
|
||||
)
|
||||
async def create_memory_fact_endpoint(request: FactCreateRequest, http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "write")
|
||||
async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request) -> MemoryResponse:
|
||||
"""Create a single fact manually."""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
try:
|
||||
memory_data, fact_id = await asyncio.to_thread(
|
||||
manager.create_fact,
|
||||
content=request.content,
|
||||
category=request.category,
|
||||
confidence=request.confidence,
|
||||
user_id=_resolve_memory_user_id(http_request),
|
||||
content=body.content,
|
||||
category=body.category,
|
||||
confidence=body.confidence,
|
||||
user_id=_resolve_memory_user_id(request),
|
||||
)
|
||||
except NotImplementedError:
|
||||
raise _unsupported_501(manager, "create fact") from None
|
||||
@ -342,11 +347,12 @@ async def create_memory_fact_endpoint(request: FactCreateRequest, http_request:
|
||||
summary="Delete Memory Fact",
|
||||
description="Delete a single saved memory fact by its fact id.",
|
||||
)
|
||||
async def delete_memory_fact_endpoint(fact_id: str, http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "write")
|
||||
async def delete_memory_fact_endpoint(fact_id: str, request: Request) -> MemoryResponse:
|
||||
"""Delete a single fact from memory by fact id."""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
try:
|
||||
memory_data = await asyncio.to_thread(manager.delete_fact, fact_id, user_id=_resolve_memory_user_id(http_request))
|
||||
memory_data = await asyncio.to_thread(manager.delete_fact, fact_id, user_id=_resolve_memory_user_id(request))
|
||||
except NotImplementedError:
|
||||
raise _unsupported_501(manager, "delete fact") from None
|
||||
except KeyError as exc:
|
||||
@ -366,17 +372,18 @@ async def delete_memory_fact_endpoint(fact_id: str, http_request: Request) -> Me
|
||||
summary="Patch Memory Fact",
|
||||
description="Partially update a single saved memory fact by its fact id while preserving omitted fields.",
|
||||
)
|
||||
async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest, http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "write")
|
||||
async def update_memory_fact_endpoint(fact_id: str, body: FactPatchRequest, request: Request) -> MemoryResponse:
|
||||
"""Partially update a single fact manually."""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
try:
|
||||
memory_data = await asyncio.to_thread(
|
||||
manager.update_fact,
|
||||
fact_id=fact_id,
|
||||
content=request.content,
|
||||
category=request.category,
|
||||
confidence=request.confidence,
|
||||
user_id=_resolve_memory_user_id(http_request),
|
||||
content=body.content,
|
||||
category=body.category,
|
||||
confidence=body.confidence,
|
||||
user_id=_resolve_memory_user_id(request),
|
||||
)
|
||||
except NotImplementedError:
|
||||
raise _unsupported_501(manager, "update fact") from None
|
||||
@ -399,10 +406,11 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest, h
|
||||
summary="Export Memory Data",
|
||||
description="Export the current global memory data as JSON for backup or transfer.",
|
||||
)
|
||||
async def export_memory(http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "read")
|
||||
async def export_memory(request: Request) -> MemoryResponse:
|
||||
"""Export the current memory data."""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(http_request), "export memory")
|
||||
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "export memory")
|
||||
return MemoryResponse(**memory_data)
|
||||
|
||||
|
||||
@ -413,14 +421,15 @@ async def export_memory(http_request: Request) -> MemoryResponse:
|
||||
summary="Import Memory Data",
|
||||
description="Import and overwrite the current global memory data from a JSON payload.",
|
||||
)
|
||||
async def import_memory(request: MemoryResponse, http_request: Request) -> MemoryResponse:
|
||||
@require_permission("memory", "write")
|
||||
async def import_memory(body: MemoryResponse, request: Request) -> MemoryResponse:
|
||||
"""Import and persist memory data."""
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
try:
|
||||
memory_data = await asyncio.to_thread(
|
||||
manager.import_memory,
|
||||
request.model_dump(exclude_none=True),
|
||||
user_id=_resolve_memory_user_id(http_request),
|
||||
body.model_dump(exclude_none=True),
|
||||
user_id=_resolve_memory_user_id(request),
|
||||
)
|
||||
except NotImplementedError:
|
||||
raise _unsupported_501(manager, "import memory") from None
|
||||
@ -438,7 +447,8 @@ async def import_memory(request: MemoryResponse, http_request: Request) -> Memor
|
||||
summary="Get Memory Configuration",
|
||||
description="Retrieve the current memory system configuration.",
|
||||
)
|
||||
async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
||||
@require_permission("memory", "read")
|
||||
async def get_memory_config_endpoint(request: Request) -> MemoryConfigResponse:
|
||||
"""Get the memory system configuration.
|
||||
|
||||
Returns:
|
||||
@ -488,7 +498,8 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
||||
summary="Get Memory Status",
|
||||
description="Retrieve both memory configuration and current data in a single request.",
|
||||
)
|
||||
async def get_memory_status(http_request: Request) -> MemoryStatusResponse:
|
||||
@require_permission("memory", "read")
|
||||
async def get_memory_status(request: Request) -> MemoryStatusResponse:
|
||||
"""Get the memory system status including configuration and data.
|
||||
|
||||
Returns:
|
||||
@ -496,7 +507,7 @@ async def get_memory_status(http_request: Request) -> MemoryStatusResponse:
|
||||
"""
|
||||
config = get_memory_config()
|
||||
manager = await asyncio.to_thread(get_memory_manager)
|
||||
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(http_request), "get memory status")
|
||||
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "get memory status")
|
||||
|
||||
return MemoryStatusResponse(
|
||||
config=MemoryConfigResponse(
|
||||
|
||||
@ -148,6 +148,9 @@ The older isolation migration remains available:
|
||||
PYTHONPATH=. python scripts/migrate_user_isolation.py --dry-run
|
||||
```
|
||||
|
||||
It assigns legacy `memory.json`, `threads/`, `agents/`, `skills/`, and the global
|
||||
`USER.md` to `--user-id` (default `default`).
|
||||
|
||||
#### Retrieval
|
||||
|
||||
`retrieval_adapter` owns indexing and retrieval.
|
||||
|
||||
@ -113,18 +113,21 @@ class Paths:
|
||||
Directory layout (host side):
|
||||
{base_dir}/
|
||||
├── memory.json
|
||||
├── USER.md <-- global user profile (injected into all agents)
|
||||
├── agents/
|
||||
├── agents/ <-- legacy shared layout (read-only fallback)
|
||||
│ └── {agent_name}/
|
||||
│ ├── config.yaml
|
||||
│ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt)
|
||||
│ └── memory.json
|
||||
└── threads/
|
||||
└── {thread_id}/
|
||||
└── user-data/ <-- mounted as /mnt/user-data/ inside sandbox
|
||||
├── workspace/ <-- /mnt/user-data/workspace/
|
||||
├── uploads/ <-- /mnt/user-data/uploads/
|
||||
└── outputs/ <-- /mnt/user-data/outputs/
|
||||
├── users/{user_id}/
|
||||
│ ├── USER.md <-- per-user profile (storage/retrieval via the user-profile routes)
|
||||
│ ├── agents/... <-- per-user custom agents (current layout)
|
||||
│ ├── skills/... <-- per-user custom skills
|
||||
│ └── threads/
|
||||
│ └── {thread_id}/
|
||||
│ └── user-data/ <-- mounted as /mnt/user-data/ inside sandbox
|
||||
│ ├── workspace/ <-- /mnt/user-data/workspace/
|
||||
│ ├── uploads/ <-- /mnt/user-data/uploads/
|
||||
│ └── outputs/ <-- /mnt/user-data/outputs/
|
||||
|
||||
BaseDir resolution (in priority order):
|
||||
1. Constructor argument `base_dir`
|
||||
@ -172,10 +175,13 @@ class Paths:
|
||||
"""Path to the persisted memory file: `{base_dir}/memory.json`."""
|
||||
return self.base_dir / "memory.json"
|
||||
|
||||
@property
|
||||
def user_md_file(self) -> Path:
|
||||
"""Path to the global user profile file: `{base_dir}/USER.md`."""
|
||||
return self.base_dir / "USER.md"
|
||||
def user_md_file(self, user_id: str) -> Path:
|
||||
"""Path to a user-scoped profile file: `{base_dir}/users/{user_id}/USER.md`.
|
||||
|
||||
The profile is per-user (like custom skills/agents) so one user's
|
||||
prompt context can never be written or injected for another user.
|
||||
"""
|
||||
return self.user_dir(user_id) / "USER.md"
|
||||
|
||||
@property
|
||||
def agents_dir(self) -> Path:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""One-time migration: move legacy thread dirs, memory, agents, and skills into per-user layout.
|
||||
"""One-time migration: move legacy thread dirs, memory, agents, skills, and the global USER.md profile into per-user layout.
|
||||
|
||||
Usage:
|
||||
PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run] [--user-id USER_ID]
|
||||
@ -244,6 +244,44 @@ def migrate_memory(
|
||||
shutil.move(str(legacy_mem), str(dest))
|
||||
|
||||
|
||||
def migrate_user_profile(
|
||||
paths: Paths,
|
||||
user_id: str = "default",
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Move the legacy global USER.md profile into per-user layout.
|
||||
|
||||
The profile became per-user (``{base_dir}/users/{user_id}/USER.md``) so
|
||||
one user's prompt context can never leak into another's; without this
|
||||
migration an existing single-user or auth-disabled installation would
|
||||
see ``content: null`` after upgrading and later strand the old file
|
||||
next to a newly created per-user one.
|
||||
|
||||
Args:
|
||||
paths: Paths instance.
|
||||
user_id: Target user to receive the legacy profile.
|
||||
dry_run: If True, only log.
|
||||
"""
|
||||
legacy_profile = paths.base_dir / "USER.md"
|
||||
if not legacy_profile.exists():
|
||||
logger.info("No legacy USER.md found — nothing to migrate.")
|
||||
return
|
||||
|
||||
dest = paths.user_md_file(user_id)
|
||||
if dest.exists():
|
||||
legacy_backup = paths.base_dir / "USER.legacy.md"
|
||||
logger.warning("Destination %s exists; renaming legacy to %s", dest, legacy_backup)
|
||||
if not dry_run:
|
||||
legacy_profile.rename(legacy_backup)
|
||||
return
|
||||
|
||||
logger.info("Migrating USER.md -> %s", dest)
|
||||
if not dry_run:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(legacy_profile), str(dest))
|
||||
|
||||
|
||||
def _build_owner_map_from_db(paths: Paths) -> dict[str, str]:
|
||||
"""Query threads_meta table for thread_id -> user_id mapping.
|
||||
|
||||
@ -274,7 +312,9 @@ def main() -> None:
|
||||
"--user-id",
|
||||
default="default",
|
||||
metavar="USER_ID",
|
||||
help=("User ID to claim un-owned legacy data (global memory.json and legacy custom agents). Defaults to 'default'. In multi-user installs, set this to the operator account that should inherit those legacy artifacts."),
|
||||
help=(
|
||||
"User ID to claim un-owned legacy data (global memory.json, USER.md profile, and legacy custom agents). Defaults to 'default'. In multi-user installs, set this to the operator account that should inherit those legacy artifacts."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -290,6 +330,7 @@ def main() -> None:
|
||||
|
||||
report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run)
|
||||
migrate_memory(paths, user_id=args.user_id, dry_run=args.dry_run)
|
||||
migrate_user_profile(paths, user_id=args.user_id, dry_run=args.dry_run)
|
||||
agent_report = migrate_agents(paths, user_id=args.user_id, dry_run=args.dry_run)
|
||||
skill_report = migrate_skills(paths, user_id=args.user_id, dry_run=args.dry_run)
|
||||
|
||||
|
||||
@ -48,6 +48,10 @@ _STUB_PERMISSIONS: list[str] = [
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -30,6 +30,10 @@ _ALL_PERMISSIONS = [
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
|
||||
@ -91,6 +91,10 @@ async def test_route_permissions_disabled_preserves_all_permissions(monkeypatch)
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
@ -110,6 +114,10 @@ async def test_route_permissions_use_async_provider_and_trusted_principal(monkey
|
||||
Permissions.THREADS_WRITE,
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
@ -121,6 +129,10 @@ async def test_route_permissions_use_async_provider_and_trusted_principal(monkey
|
||||
("route", "create", Permissions.RUNS_CREATE),
|
||||
("route", "read", Permissions.RUNS_READ),
|
||||
("route", "cancel", Permissions.RUNS_CANCEL),
|
||||
("route", "read", Permissions.MEMORY_READ),
|
||||
("route", "write", Permissions.MEMORY_WRITE),
|
||||
("route", "read", Permissions.AGENTS_READ),
|
||||
("route", "write", Permissions.AGENTS_WRITE),
|
||||
("route", "read", Permissions.PROJECTS_READ),
|
||||
("route", "write", Permissions.PROJECTS_WRITE),
|
||||
("route", "delete", Permissions.PROJECTS_DELETE),
|
||||
@ -146,6 +158,10 @@ async def test_route_permissions_fail_closed_denies_only_the_failed_permission(m
|
||||
Permissions.THREADS_DELETE,
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
@ -166,6 +182,10 @@ async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypa
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
@ -186,6 +206,10 @@ async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypa
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
Permissions.MEMORY_READ,
|
||||
Permissions.MEMORY_WRITE,
|
||||
Permissions.AGENTS_READ,
|
||||
Permissions.AGENTS_WRITE,
|
||||
Permissions.PROJECTS_READ,
|
||||
Permissions.PROJECTS_WRITE,
|
||||
Permissions.PROJECTS_DELETE,
|
||||
|
||||
@ -68,7 +68,8 @@ class TestPaths:
|
||||
|
||||
def test_user_md_file(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
assert paths.user_md_file == tmp_path / "USER.md"
|
||||
assert paths.user_md_file("alice") == tmp_path / "users" / "alice" / "USER.md"
|
||||
assert paths.user_md_file("bob") != paths.user_md_file("alice")
|
||||
|
||||
def test_paths_are_different_from_global(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
@ -529,12 +530,17 @@ def _stub_app_config():
|
||||
|
||||
|
||||
def _make_test_app(tmp_path: Path):
|
||||
"""Create a FastAPI app with the agents router, patching paths to tmp_path."""
|
||||
from fastapi import FastAPI
|
||||
"""Create a FastAPI app with the agents router, patching paths to tmp_path.
|
||||
|
||||
Uses the stub-auth helper so the ``@require_permission`` decorators on the
|
||||
agents routes see an authenticated user with all permissions (mirroring
|
||||
what ``AuthMiddleware`` does in the real gateway).
|
||||
"""
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
|
||||
from app.gateway.routers.agents import router
|
||||
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@ -881,11 +887,29 @@ class TestUserProfileAPI:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == content
|
||||
|
||||
# File should be written to disk
|
||||
user_md = tmp_path / "USER.md"
|
||||
# File should be written to the caller's per-user bucket. The autouse
|
||||
# _auto_user_context fixture in conftest.py sets user
|
||||
# "test-user-autouse", so that is the effective id here.
|
||||
user_md = tmp_path / "users" / "test-user-autouse" / "USER.md"
|
||||
assert user_md.exists()
|
||||
assert user_md.read_text(encoding="utf-8") == content
|
||||
|
||||
def test_user_profile_is_isolated_per_user(self, agent_client, tmp_path):
|
||||
"""A legacy global USER.md must never leak into a user's profile read.
|
||||
|
||||
Pre-fix behavior: GET/PUT /api/user-profile read and wrote the shared
|
||||
``{base_dir}/USER.md`` singleton, so any authenticated user could
|
||||
overwrite the prompt context injected for every other user.
|
||||
"""
|
||||
legacy_global = tmp_path / "USER.md"
|
||||
legacy_global.write_text("# injected by another user", encoding="utf-8")
|
||||
|
||||
got = agent_client.get("/api/user-profile")
|
||||
assert got.status_code == 200
|
||||
# Per-user file does not exist yet and the legacy global file is not
|
||||
# consulted as a fallback.
|
||||
assert got.json()["content"] is None
|
||||
|
||||
def test_get_user_profile_after_put(self, agent_client):
|
||||
content = "# Profile\n\nI work on data science."
|
||||
agent_client.put("/api/user-profile", json={"content": content})
|
||||
|
||||
@ -4,7 +4,7 @@ import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from _router_auth_helpers import call_unwrapped, make_authed_test_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.routers import memory
|
||||
@ -34,7 +34,7 @@ def _sample_memory(facts: list[dict] | None = None) -> dict:
|
||||
|
||||
|
||||
def test_export_memory_route_returns_current_memory() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
exported_memory = _sample_memory(facts=[{"id": "fact_export", "content": "User prefers concise responses.", "category": "preference", "confidence": 0.9, "createdAt": "2026-03-20T00:00:00Z", "source": "thread-1"}])
|
||||
|
||||
@ -62,14 +62,14 @@ def test_get_memory_route_offloads_manager_call_from_event_loop() -> None:
|
||||
patch("app.gateway.routers.memory.get_memory_manager", return_value=manager),
|
||||
patch("app.gateway.routers.memory._resolve_memory_user_id", return_value="user-1"),
|
||||
):
|
||||
response = asyncio.run(memory.get_memory(request))
|
||||
response = asyncio.run(call_unwrapped(memory.get_memory, request))
|
||||
|
||||
assert response.facts == []
|
||||
assert called_from and called_from[0] != event_loop_thread
|
||||
|
||||
|
||||
def test_export_memory_route_preserves_source_error() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
exported_memory = _sample_memory(
|
||||
facts=[
|
||||
@ -98,7 +98,7 @@ def test_export_memory_route_preserves_source_error() -> None:
|
||||
|
||||
|
||||
def test_import_memory_route_returns_imported_memory() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
imported_memory = _sample_memory(facts=[{"id": "fact_import", "content": "User works on DeerFlow.", "category": "context", "confidence": 0.87, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
|
||||
|
||||
@ -112,7 +112,7 @@ def test_import_memory_route_returns_imported_memory() -> None:
|
||||
|
||||
|
||||
def test_import_route_without_agent_name_persists_default_bucket_markdown(tmp_path) -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
|
||||
imported_memory = _sample_memory(
|
||||
@ -142,7 +142,7 @@ def test_import_route_without_agent_name_persists_default_bucket_markdown(tmp_pa
|
||||
|
||||
|
||||
def test_import_memory_route_preserves_source_error() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
imported_memory = _sample_memory(
|
||||
facts=[
|
||||
@ -171,7 +171,7 @@ def test_import_memory_route_preserves_source_error() -> None:
|
||||
|
||||
|
||||
def test_clear_memory_route_returns_cleared_memory() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.clear_memory.return_value = _sample_memory()
|
||||
@ -186,7 +186,7 @@ def test_clear_memory_route_returns_cleared_memory() -> None:
|
||||
|
||||
|
||||
def test_create_memory_fact_route_returns_updated_memory() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
updated_memory = _sample_memory(facts=[{"id": "fact_new", "content": "User prefers concise code reviews.", "category": "preference", "confidence": 0.88, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
|
||||
|
||||
@ -200,7 +200,7 @@ def test_create_memory_fact_route_returns_updated_memory() -> None:
|
||||
|
||||
|
||||
def test_create_memory_fact_route_maps_conflict_to_409() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_fact.side_effect = MemoryConflictError("stale write")
|
||||
@ -214,7 +214,7 @@ def test_create_memory_fact_route_maps_conflict_to_409() -> None:
|
||||
|
||||
|
||||
def test_create_memory_fact_route_maps_duplicate_to_409() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_fact.side_effect = ValueError("Duplicate fact")
|
||||
@ -228,7 +228,7 @@ def test_create_memory_fact_route_maps_duplicate_to_409() -> None:
|
||||
|
||||
|
||||
def test_get_memory_route_maps_corruption_to_stable_500() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get_memory.side_effect = MemoryCorruptionError("private path and parser detail")
|
||||
@ -242,7 +242,7 @@ def test_get_memory_route_maps_corruption_to_stable_500() -> None:
|
||||
|
||||
|
||||
def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
updated_memory = _sample_memory(facts=[{"id": "fact_keep", "content": "User likes Python", "category": "preference", "confidence": 0.9, "createdAt": "2026-03-20T00:00:00Z", "source": "thread-1"}])
|
||||
|
||||
@ -256,7 +256,7 @@ def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
||||
|
||||
|
||||
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.delete_fact.side_effect = KeyError("fact_missing")
|
||||
@ -268,7 +268,7 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
|
||||
|
||||
def test_update_memory_fact_route_returns_updated_memory() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
updated_memory = _sample_memory(facts=[{"id": "fact_edit", "content": "User prefers spaces", "category": "workflow", "confidence": 0.91, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
|
||||
|
||||
@ -283,7 +283,7 @@ def test_update_memory_fact_route_returns_updated_memory() -> None:
|
||||
|
||||
def test_settings_fact_crud_without_agent_name_uses_default_agent(tmp_path) -> None:
|
||||
"""The current Settings API sends no agent_name; it must remain usable."""
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
memory_path = tmp_path / "users" / "alice" / "memory.json"
|
||||
memory_path.parent.mkdir(parents=True)
|
||||
@ -339,7 +339,7 @@ def test_settings_fact_crud_without_agent_name_uses_default_agent(tmp_path) -> N
|
||||
|
||||
|
||||
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
updated_memory = _sample_memory(facts=[{"id": "fact_edit", "content": "User prefers spaces", "category": "preference", "confidence": 0.8, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
|
||||
|
||||
@ -363,7 +363,7 @@ def test_update_memory_fact_route_preserves_omitted_fields() -> None:
|
||||
|
||||
|
||||
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.update_fact.side_effect = KeyError("fact_missing")
|
||||
@ -375,7 +375,7 @@ def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
|
||||
|
||||
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.update_fact.side_effect = ValueError("confidence")
|
||||
@ -409,7 +409,7 @@ def test_get_memory_honors_bound_owner_header() -> None:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get_memory.side_effect = fake_get_memory
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
|
||||
response = asyncio.run(memory.get_memory(_internal_owner_request("owner-1")))
|
||||
response = asyncio.run(call_unwrapped(memory.get_memory, _internal_owner_request("owner-1")))
|
||||
assert seen["user_id"] == "owner-1"
|
||||
assert response.facts[0].content == "owner fact"
|
||||
|
||||
@ -427,7 +427,7 @@ def test_get_memory_sanitizes_unsafe_owner_header() -> None:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get_memory.side_effect = fake_get_memory
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
|
||||
asyncio.run(memory.get_memory(_internal_owner_request(raw_owner)))
|
||||
asyncio.run(call_unwrapped(memory.get_memory, _internal_owner_request(raw_owner)))
|
||||
expected = make_safe_user_id(raw_owner)
|
||||
assert seen["user_id"] == expected
|
||||
assert seen["user_id"] != raw_owner
|
||||
@ -451,7 +451,7 @@ def test_get_memory_falls_back_to_effective_user_for_browser_requests() -> None:
|
||||
mock_mgr.get_memory.side_effect = fake_get_memory
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
|
||||
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
|
||||
asyncio.run(memory.get_memory(browser_request))
|
||||
asyncio.run(call_unwrapped(memory.get_memory, browser_request))
|
||||
assert seen["user_id"] == "real-user"
|
||||
|
||||
|
||||
@ -474,11 +474,11 @@ def test_clear_memory_scopes_destructive_write_to_bound_owner() -> None:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.clear_memory.side_effect = fake_clear
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
|
||||
asyncio.run(memory.clear_memory(_internal_owner_request("owner-1")))
|
||||
asyncio.run(call_unwrapped(memory.clear_memory, _internal_owner_request("owner-1")))
|
||||
assert seen["user_id"] == "owner-1"
|
||||
|
||||
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
|
||||
asyncio.run(memory.clear_memory(_browser_request_with_spoofed_owner_header()))
|
||||
asyncio.run(call_unwrapped(memory.clear_memory, _browser_request_with_spoofed_owner_header()))
|
||||
assert seen["user_id"] == "real-user"
|
||||
|
||||
|
||||
@ -493,11 +493,11 @@ def test_import_memory_scopes_overwrite_to_bound_owner() -> None:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.import_memory.side_effect = fake_import
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
|
||||
asyncio.run(memory.import_memory(payload, _internal_owner_request("owner-1")))
|
||||
asyncio.run(call_unwrapped(memory.import_memory, payload, _internal_owner_request("owner-1")))
|
||||
assert seen["user_id"] == "owner-1"
|
||||
|
||||
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
|
||||
asyncio.run(memory.import_memory(payload, _browser_request_with_spoofed_owner_header()))
|
||||
asyncio.run(call_unwrapped(memory.import_memory, payload, _browser_request_with_spoofed_owner_header()))
|
||||
assert seen["user_id"] == "real-user"
|
||||
|
||||
|
||||
@ -522,7 +522,7 @@ def _unsupported_manager() -> MagicMock:
|
||||
|
||||
|
||||
def test_get_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
|
||||
with TestClient(app) as client:
|
||||
@ -532,7 +532,7 @@ def test_get_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
|
||||
|
||||
def test_export_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
|
||||
with TestClient(app) as client:
|
||||
@ -541,7 +541,7 @@ def test_export_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
|
||||
|
||||
def test_memory_status_route_returns_501_for_unsupported_backend() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
cfg = SimpleNamespace(
|
||||
enabled=True,
|
||||
@ -561,7 +561,7 @@ def test_memory_status_route_returns_501_for_unsupported_backend() -> None:
|
||||
|
||||
|
||||
def test_clear_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
|
||||
with TestClient(app) as client:
|
||||
@ -570,7 +570,7 @@ def test_clear_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
|
||||
|
||||
def test_import_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
|
||||
with TestClient(app) as client:
|
||||
@ -581,7 +581,7 @@ def test_import_memory_route_returns_501_for_unsupported_backend() -> None:
|
||||
def test_reload_memory_route_returns_501_when_read_also_unsupported() -> None:
|
||||
"""reload falls back to get_memory; if both raise (minimal backend), the
|
||||
fallback surfaces 501 instead of a raw 500 from the uncaught raise."""
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app()
|
||||
app.include_router(memory.router)
|
||||
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
|
||||
with TestClient(app) as client:
|
||||
|
||||
@ -279,3 +279,65 @@ class TestMigrateSkills:
|
||||
assert not (base_dir / "skills" / "custom").exists()
|
||||
assert (base_dir / "skills").exists()
|
||||
assert (base_dir / "skills" / "public").exists()
|
||||
|
||||
|
||||
class TestMigrateUserProfile:
|
||||
def test_moves_global_user_md(self, base_dir: Path, paths: Paths):
|
||||
legacy = base_dir / "USER.md"
|
||||
legacy.write_text("# About me\n", encoding="utf-8")
|
||||
|
||||
from scripts.migrate_user_isolation import migrate_user_profile
|
||||
|
||||
migrate_user_profile(paths, user_id="default")
|
||||
|
||||
expected = base_dir / "users" / "default" / "USER.md"
|
||||
assert expected.read_text(encoding="utf-8") == "# About me\n"
|
||||
assert not legacy.exists()
|
||||
|
||||
def test_conflict_renames_legacy_and_keeps_destination(self, base_dir: Path, paths: Paths):
|
||||
legacy = base_dir / "USER.md"
|
||||
legacy.write_text("# legacy\n", encoding="utf-8")
|
||||
|
||||
dest = base_dir / "users" / "default" / "USER.md"
|
||||
dest.parent.mkdir(parents=True)
|
||||
dest.write_text("# current\n", encoding="utf-8")
|
||||
|
||||
from scripts.migrate_user_isolation import migrate_user_profile
|
||||
|
||||
migrate_user_profile(paths, user_id="default")
|
||||
|
||||
# The per-user copy wins; the legacy global file is preserved aside
|
||||
# instead of silently overwritten.
|
||||
assert dest.read_text(encoding="utf-8") == "# current\n"
|
||||
assert (base_dir / "USER.legacy.md").read_text(encoding="utf-8") == "# legacy\n"
|
||||
|
||||
def test_no_legacy_user_md_is_noop(self, base_dir: Path, paths: Paths):
|
||||
from scripts.migrate_user_isolation import migrate_user_profile
|
||||
|
||||
migrate_user_profile(paths, user_id="default") # should not raise
|
||||
|
||||
def test_dry_run_moves_nothing(self, base_dir: Path, paths: Paths):
|
||||
legacy = base_dir / "USER.md"
|
||||
legacy.write_text("# About me\n", encoding="utf-8")
|
||||
|
||||
from scripts.migrate_user_isolation import migrate_user_profile
|
||||
|
||||
migrate_user_profile(paths, user_id="default", dry_run=True)
|
||||
|
||||
assert legacy.exists()
|
||||
assert not (base_dir / "users" / "default" / "USER.md").exists()
|
||||
|
||||
def test_dry_run_conflict_renames_nothing(self, base_dir: Path, paths: Paths):
|
||||
legacy = base_dir / "USER.md"
|
||||
legacy.write_text("# legacy\n", encoding="utf-8")
|
||||
dest = base_dir / "users" / "default" / "USER.md"
|
||||
dest.parent.mkdir(parents=True)
|
||||
dest.write_text("# current\n", encoding="utf-8")
|
||||
|
||||
from scripts.migrate_user_isolation import migrate_user_profile
|
||||
|
||||
migrate_user_profile(paths, user_id="default", dry_run=True)
|
||||
|
||||
assert not (base_dir / "USER.legacy.md").exists()
|
||||
assert dest.read_text(encoding="utf-8") == "# current\n"
|
||||
assert legacy.exists()
|
||||
|
||||
@ -89,10 +89,16 @@ def test_validate_scopes_deduplicates_and_rejects_unknown():
|
||||
|
||||
|
||||
def test_pat_scopes_stay_aligned_with_route_permissions():
|
||||
"""PAT scopes are exactly the authz route permissions — fail on drift."""
|
||||
"""Every PAT scope must be a real route permission, and the memory/agent
|
||||
permissions are deliberately excluded: PATs govern the thread/run
|
||||
lifecycle only (_PAT_ROUTE_RULES default-denies those routers for PAT
|
||||
callers regardless of scopes). Fail on drift in either direction."""
|
||||
from app.gateway.authz import _ALL_PERMISSIONS
|
||||
|
||||
assert PAT_ALLOWED_SCOPES == frozenset(_ALL_PERMISSIONS)
|
||||
route_permissions = frozenset(_ALL_PERMISSIONS)
|
||||
assert PAT_ALLOWED_SCOPES <= route_permissions
|
||||
# The deliberate exclusions, pinned so they cannot drift silently.
|
||||
assert not (PAT_ALLOWED_SCOPES & {"memory:read", "memory:write", "agents:read", "agents:write"})
|
||||
|
||||
|
||||
# ── Repository ────────────────────────────────────────────────────────────
|
||||
|
||||
@ -946,7 +946,14 @@ def test_insert_race_recovery_claims_unscoped_row_for_trusted_owner() -> None:
|
||||
thread_store = _RacingOwnerStore(store)
|
||||
request = SimpleNamespace(
|
||||
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE), auth_source=AUTH_SOURCE_INTERNAL),
|
||||
# Realistic internal-auth fields: the create_thread permission wrapper
|
||||
# authenticates these direct calls, and get_current_user_from_request
|
||||
# honors state.user only when auth_source marks a trusted origin.
|
||||
cookies={},
|
||||
state=SimpleNamespace(
|
||||
user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE),
|
||||
auth_source=AUTH_SOURCE_INTERNAL,
|
||||
),
|
||||
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
|
||||
)
|
||||
|
||||
@ -1204,7 +1211,14 @@ def test_internal_owner_header_assigns_thread_to_owner() -> None:
|
||||
thread_store = MemoryThreadMetaStore(store)
|
||||
request = SimpleNamespace(
|
||||
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE), auth_source=AUTH_SOURCE_INTERNAL),
|
||||
# Realistic internal-auth fields: the create_thread permission wrapper
|
||||
# authenticates these direct calls, and get_current_user_from_request
|
||||
# honors state.user only when auth_source marks a trusted origin.
|
||||
cookies={},
|
||||
state=SimpleNamespace(
|
||||
user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE),
|
||||
auth_source=AUTH_SOURCE_INTERNAL,
|
||||
),
|
||||
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
|
||||
)
|
||||
|
||||
@ -1236,7 +1250,14 @@ def test_goal_thread_creation_uses_internal_owner_header() -> None:
|
||||
thread_store = MemoryThreadMetaStore(store)
|
||||
request = SimpleNamespace(
|
||||
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE), auth_source=AUTH_SOURCE_INTERNAL),
|
||||
# Realistic internal-auth fields: the create_thread permission wrapper
|
||||
# authenticates these direct calls, and get_current_user_from_request
|
||||
# honors state.user only when auth_source marks a trusted origin.
|
||||
cookies={},
|
||||
state=SimpleNamespace(
|
||||
user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE),
|
||||
auth_source=AUTH_SOURCE_INTERNAL,
|
||||
),
|
||||
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
|
||||
)
|
||||
|
||||
|
||||
@ -16,6 +16,10 @@ const AUTH_DISABLED_PERMISSIONS = [
|
||||
"runs:create",
|
||||
"runs:read",
|
||||
"runs:cancel",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"agents:read",
|
||||
"agents:write",
|
||||
"projects:read",
|
||||
"projects:write",
|
||||
"projects:delete",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user