diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py index f2554adcd74..f864de37144 100644 --- a/backend/app/gateway/authz.py +++ b/backend/app/gateway/authz.py @@ -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 @@ -67,6 +71,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. @@ -125,6 +137,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, ] @@ -550,12 +566,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) @@ -592,6 +618,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") diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 20da60ea9de..2d7de79861b 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -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, @@ -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: @@ -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: @@ -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: @@ -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. @@ -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) @@ -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)}") @@ -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. @@ -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 @@ -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}'") @@ -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") @@ -491,10 +499,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 (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. @@ -502,7 +515,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() @@ -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. @@ -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)}") @@ -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: diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index b69c1257310..cf0e2ad021c 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -6,6 +6,7 @@ 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( diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index 8aeb1d95cbf..6278bbd7b4f 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -781,6 +781,7 @@ def _existing_thread_response(thread_id: str, record: dict) -> ThreadResponse: @router.post("", response_model=ThreadResponse) +@require_permission("threads", "write") async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadResponse: """Create a new thread. @@ -1055,6 +1056,7 @@ def branch_values(source_snapshot: Any) -> dict[str, Any]: @router.post("/search", response_model=list[ThreadResponse]) +@require_permission("threads", "read") async def search_threads(body: ThreadSearchRequest, request: Request) -> list[ThreadResponse]: """Search and list threads. diff --git a/backend/packages/harness/deerflow/config/paths.py b/backend/packages/harness/deerflow/config/paths.py index af5abafd3dc..3cbf093868e 100644 --- a/backend/packages/harness/deerflow/config/paths.py +++ b/backend/packages/harness/deerflow/config/paths.py @@ -106,18 +106,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 (injected into that user's agents) + │ ├── 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` @@ -165,10 +168,13 @@ def memory_file(self) -> Path: """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: diff --git a/backend/scripts/migrate_user_isolation.py b/backend/scripts/migrate_user_isolation.py index 4a7e5d120e6..03fd6f48012 100644 --- a/backend/scripts/migrate_user_isolation.py +++ b/backend/scripts/migrate_user_isolation.py @@ -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) diff --git a/backend/tests/_router_auth_helpers.py b/backend/tests/_router_auth_helpers.py index 2bd2ebdee49..d4b892ecdaa 100644 --- a/backend/tests/_router_auth_helpers.py +++ b/backend/tests/_router_auth_helpers.py @@ -48,6 +48,10 @@ Permissions.RUNS_CREATE, Permissions.RUNS_READ, Permissions.RUNS_CANCEL, + Permissions.MEMORY_READ, + Permissions.MEMORY_WRITE, + Permissions.AGENTS_READ, + Permissions.AGENTS_WRITE, ] diff --git a/backend/tests/test_authorization_route_permissions.py b/backend/tests/test_authorization_route_permissions.py index 8318fd0dbb7..596d04893d1 100644 --- a/backend/tests/test_authorization_route_permissions.py +++ b/backend/tests/test_authorization_route_permissions.py @@ -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, ] cached.assert_not_called() @@ -107,6 +111,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, ] assert [(request.resource, request.action, request.target) for request in provider.requests] == [ ("route", "read", Permissions.THREADS_READ), @@ -115,6 +123,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), ] principal = provider.requests[0].principal assert principal.user_id == "user-123" @@ -137,6 +149,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, ] @@ -154,6 +170,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, ] @@ -171,6 +191,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, ], ), ], diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index 285b3af6f1d..7ca6da75727 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -68,7 +68,8 @@ def test_agent_memory_file(self, tmp_path): 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 @@ -835,11 +841,29 @@ def test_put_user_profile(self, agent_client, tmp_path): 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}) diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py index 9d717728318..517744b7bd3 100644 --- a/backend/tests/test_memory_router.py +++ b/backend/tests/test_memory_router.py @@ -4,7 +4,7 @@ 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 get_memory(*, user_id: str) -> dict: 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 fake_get_memory(*, user_id: str) -> dict: 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 fake_get_memory(*, user_id: str) -> dict: 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 fake_get_memory(*, user_id: str) -> dict: 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 fake_clear(*, user_id: str) -> dict: 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 fake_import(_data: dict, *, user_id: str) -> dict: 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: diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index 619badf204b..796d6aaf766 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -742,6 +742,7 @@ def test_insert_race_recovery_claims_unscoped_row_for_trusted_owner() -> None: from sqlalchemy.exc import IntegrityError + from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE store = InMemoryStore() @@ -764,7 +765,14 @@ async def create(self, thread_id, *, assistant_id=None, user_id=None, display_na 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)), + # 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)), ) @@ -895,6 +903,7 @@ async def _seed_active_run() -> None: def test_internal_owner_header_assigns_thread_to_owner() -> None: import asyncio + from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE store = InMemoryStore() @@ -902,7 +911,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)), + # 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)), ) @@ -926,6 +942,7 @@ async def _scenario(): def test_goal_thread_creation_uses_internal_owner_header() -> None: import asyncio + from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE store = InMemoryStore() @@ -933,7 +950,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)), + # 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)), )