Compare commits

..

5 Commits

Author SHA1 Message Date
Zamil Majdy
54645c9747 fix(blocks): add HasRecipients Protocol, extract validate_all_recipients, add forward test
- Add typed HasRecipients Protocol for validate_all_recipients parameter
- Extract duplicate validation into validate_all_recipients() helper
- Remove duplicate comments at three call sites
- Add TestForwardMessageValidation integration tests
2026-03-31 12:20:37 +02:00
Zamil Majdy
fbb3af2c14 test(blocks): add integration tests for email validation in create_mime_message and _build_reply_message
Verify that ValueError is raised for invalid recipients in to/cc/bcc
fields, including auto-resolved recipients from parent message headers.
2026-03-25 19:13:23 +07:00
Zamil Majdy
b217d47de5 fix(blocks): strip whitespace in MIME headers and align to-validation in reply path
- Updated serialize_email_recipients() to strip whitespace from each
  address, keeping MIME headers consistent with the .strip() already
  applied in validate_email_recipients().
- Replaced all raw ", ".join() calls for recipient headers with
  serialize_email_recipients() across create_mime_message,
  _build_reply_message, and _forward_message.
- Made to-field validation unconditional in _build_reply_message(),
  matching the pattern in create_mime_message() and _forward_message().
2026-03-25 18:59:55 +07:00
Zamil Majdy
a9776b58cc fix(blocks): add email validation to GmailForwardBlock._forward_message()
The forward block bypassed the new validate_email_recipients() check
since it constructs its own MIME message inline rather than going
through create_mime_message() or _build_reply_message().
2026-03-25 18:04:57 +07:00
Krishna Chaitanya Balusu
aa749c347d fix(blocks): validate email recipients in Gmail blocks before API call
Addresses #11954 — GmailSendBlock crashes with an opaque "Invalid To
header" HttpError 400 when the LLM (or user) supplies a malformed
recipient such as a bare username, a JSON string, or an empty value.

Add a lightweight `validate_email_recipients()` check in the shared
`create_mime_message()` path and in `_build_reply_message()` so that
every Gmail block that sends or drafts email gets upfront validation
with a clear, actionable error message listing the invalid entries.
2026-03-24 23:17:38 -04:00
25 changed files with 488 additions and 1895 deletions

View File

@@ -113,9 +113,7 @@ kill $REST_PID 2>/dev/null; trap - EXIT
```
Never manually edit files in `src/app/api/__generated__/`.
Then commit and **push immediately** — never batch commits without pushing. Each fix should be visible on GitHub right away so CI can start and reviewers can see progress.
**Never push empty commits** (`git commit --allow-empty`) to re-trigger CI or bot checks. When a check fails, investigate the root cause (unchecked PR checklist, unaddressed review comments, code issues) and fix those directly. Empty commits add noise to git history.
Then commit and **push immediately** — never batch commits without pushing.
For backend commits in worktrees: `poetry run git commit` (pre-commit hooks).

View File

@@ -53,7 +53,6 @@ AutoGPT Platform is a monorepo containing:
### Creating Pull Requests
- Create the PR against the `dev` branch of the repository.
- **Split PRs by concern** — each PR should have a single clear purpose. For example, "usage tracking" and "credit charging" should be separate PRs even if related. Combining multiple concerns makes it harder for reviewers to understand what belongs to what.
- Ensure the branch name is descriptive (e.g., `feature/add-new-block`)
- Use conventional commit messages (see below)
- **Structure the PR description with Why / What / How** — Why: the motivation (what problem it solves, what's broken/missing without it); What: high-level summary of changes; How: approach, key implementation details, or architecture decisions. Reviewers need all three to judge whether the approach fits the problem.

View File

@@ -61,7 +61,6 @@ poetry run pytest path/to/test.py --snapshot-update
## Code Style
- **Top-level imports only** — no local/inner imports (lazy imports only for heavy optional deps like `openpyxl`)
- **Absolute imports** — use `from backend.module import ...` for cross-package imports. Single-dot relative (`from .sibling import ...`) is acceptable for sibling modules within the same package (e.g., blocks). Avoid double-dot relative imports (`from ..parent import ...`) — use the absolute path instead
- **No duck typing** — no `hasattr`/`getattr`/`isinstance` for type dispatch; use typed interfaces/unions/protocols
- **Pydantic models** over dataclass/namedtuple/dict for structured data
- **No linter suppressors** — no `# type: ignore`, `# noqa`, `# pyright: ignore`; fix the type/code

View File

@@ -7,8 +7,6 @@ import fastapi
import fastapi.responses
import prisma.enums
import backend.api.features.library.db as library_db
import backend.api.features.library.model as library_model
import backend.api.features.store.cache as store_cache
import backend.api.features.store.db as store_db
import backend.api.features.store.model as store_model
@@ -134,40 +132,3 @@ async def admin_download_agent_file(
return fastapi.responses.FileResponse(
tmp_file.name, filename=file_name, media_type="application/json"
)
@router.get(
"/submissions/{store_listing_version_id}/preview",
summary="Admin Preview Submission Listing",
)
async def admin_preview_submission(
store_listing_version_id: str,
) -> store_model.StoreAgentDetails:
"""
Preview a marketplace submission as it would appear on the listing page.
Bypasses the APPROVED-only StoreAgent view so admins can preview pending
submissions before approving.
"""
return await store_db.get_store_agent_details_as_admin(store_listing_version_id)
@router.post(
"/submissions/{store_listing_version_id}/add-to-library",
summary="Admin Add Pending Agent to Library",
status_code=201,
)
async def admin_add_agent_to_library(
store_listing_version_id: str,
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
) -> library_model.LibraryAgent:
"""
Add a pending marketplace agent to the admin's library for review.
Uses admin-level access to bypass marketplace APPROVED-only checks.
The builder can load the graph because get_graph() checks library
membership as a fallback: "you added it, you keep it."
"""
return await library_db.add_store_agent_to_library_as_admin(
store_listing_version_id=store_listing_version_id,
user_id=user_id,
)

View File

@@ -1,33 +1,14 @@
"""Tests for admin store routes and the bypass logic they depend on.
Tests are organized by what they protect:
- SECRT-2162: get_graph_as_admin bypasses ownership/marketplace checks
- SECRT-2167 security: admin endpoints reject non-admin users
- SECRT-2167 bypass: preview queries StoreListingVersion (not StoreAgent view),
and add-to-library uses get_graph_as_admin (not get_graph)
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import fastapi
import fastapi.responses
import fastapi.testclient
import pytest
import pytest_mock
from autogpt_libs.auth.jwt_utils import get_jwt_payload
from backend.data.graph import get_graph_as_admin
from backend.util.exceptions import NotFoundError
from .store_admin_routes import router as store_admin_router
# Shared constants
ADMIN_USER_ID = "admin-user-id"
CREATOR_USER_ID = "other-creator-id"
GRAPH_ID = "test-graph-id"
GRAPH_VERSION = 3
SLV_ID = "test-store-listing-version-id"
def _make_mock_graph(user_id: str = CREATOR_USER_ID) -> MagicMock:
@@ -39,18 +20,18 @@ def _make_mock_graph(user_id: str = CREATOR_USER_ID) -> MagicMock:
return graph
# ---- SECRT-2162: get_graph_as_admin bypasses ownership checks ---- #
@pytest.mark.asyncio
async def test_admin_can_access_pending_agent_not_owned() -> None:
"""get_graph_as_admin must return a graph even when the admin doesn't own
it and it's not APPROVED in the marketplace."""
"""Admin must be able to access a graph they don't own even if it's not
APPROVED in the marketplace. This is the core use case: reviewing a
submitted-but-pending agent from the admin dashboard."""
mock_graph = _make_mock_graph()
mock_graph_model = MagicMock(name="GraphModel")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_prisma,
patch(
"backend.data.graph.AgentGraph.prisma",
) as mock_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
@@ -65,19 +46,25 @@ async def test_admin_can_access_pending_agent_not_owned() -> None:
for_export=False,
)
assert (
result is not None
), "Admin should be able to access a pending agent they don't own"
assert result is mock_graph_model
@pytest.mark.asyncio
async def test_admin_download_pending_agent_with_subagents() -> None:
"""get_graph_as_admin with for_export=True must call get_sub_graphs
and pass sub_graphs to GraphModel.from_db."""
"""Admin export (for_export=True) of a pending agent must include
sub-graphs. This exercises the full export code path that the Download
button uses."""
mock_graph = _make_mock_graph()
mock_sub_graph = MagicMock(name="SubGraph")
mock_graph_model = MagicMock(name="GraphModel")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_prisma,
patch(
"backend.data.graph.AgentGraph.prisma",
) as mock_prisma,
patch(
"backend.data.graph.get_sub_graphs",
new_callable=AsyncMock,
@@ -97,239 +84,10 @@ async def test_admin_download_pending_agent_with_subagents() -> None:
for_export=True,
)
assert result is mock_graph_model
assert result is not None, "Admin export of pending agent must succeed"
mock_get_sub.assert_awaited_once_with(mock_graph)
mock_from_db.assert_called_once_with(
graph=mock_graph,
sub_graphs=[mock_sub_graph],
for_export=True,
)
# ---- SECRT-2167 security: admin endpoints reject non-admin users ---- #
app = fastapi.FastAPI()
app.include_router(store_admin_router)
@app.exception_handler(NotFoundError)
async def _not_found_handler(
request: fastapi.Request, exc: NotFoundError
) -> fastapi.responses.JSONResponse:
return fastapi.responses.JSONResponse(status_code=404, content={"detail": str(exc)})
client = fastapi.testclient.TestClient(app)
@pytest.fixture(autouse=True)
def setup_app_admin_auth(mock_jwt_admin):
"""Setup admin auth overrides for all route tests in this module."""
app.dependency_overrides[get_jwt_payload] = mock_jwt_admin["get_jwt_payload"]
yield
app.dependency_overrides.clear()
def test_preview_requires_admin(mock_jwt_user) -> None:
"""Non-admin users must get 403 on the preview endpoint."""
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
response = client.get(f"/admin/submissions/{SLV_ID}/preview")
assert response.status_code == 403
def test_add_to_library_requires_admin(mock_jwt_user) -> None:
"""Non-admin users must get 403 on the add-to-library endpoint."""
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
response = client.post(f"/admin/submissions/{SLV_ID}/add-to-library")
assert response.status_code == 403
def test_preview_nonexistent_submission(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Preview of a nonexistent submission returns 404."""
mocker.patch(
"backend.api.features.admin.store_admin_routes.store_db"
".get_store_agent_details_as_admin",
side_effect=NotFoundError("not found"),
)
response = client.get(f"/admin/submissions/{SLV_ID}/preview")
assert response.status_code == 404
# ---- SECRT-2167 bypass: verify the right data sources are used ---- #
@pytest.mark.asyncio
async def test_preview_queries_store_listing_version_not_store_agent() -> None:
"""get_store_agent_details_as_admin must query StoreListingVersion
directly (not the APPROVED-only StoreAgent view). This is THE test that
prevents the bypass from being accidentally reverted."""
from backend.api.features.store.db import get_store_agent_details_as_admin
mock_slv = MagicMock()
mock_slv.id = SLV_ID
mock_slv.name = "Test Agent"
mock_slv.subHeading = "Short desc"
mock_slv.description = "Long desc"
mock_slv.videoUrl = None
mock_slv.agentOutputDemoUrl = None
mock_slv.imageUrls = ["https://example.com/img.png"]
mock_slv.instructions = None
mock_slv.categories = ["productivity"]
mock_slv.version = 1
mock_slv.agentGraphId = GRAPH_ID
mock_slv.agentGraphVersion = GRAPH_VERSION
mock_slv.updatedAt = datetime(2026, 3, 24, tzinfo=timezone.utc)
mock_slv.recommendedScheduleCron = "0 9 * * *"
mock_listing = MagicMock()
mock_listing.id = "listing-id"
mock_listing.slug = "test-agent"
mock_listing.activeVersionId = SLV_ID
mock_listing.hasApprovedVersion = False
mock_listing.CreatorProfile = MagicMock(username="creator", avatarUrl="")
mock_slv.StoreListing = mock_listing
with (
patch(
"backend.api.features.store.db.prisma.models" ".StoreListingVersion.prisma",
) as mock_slv_prisma,
patch(
"backend.api.features.store.db.prisma.models.StoreAgent.prisma",
) as mock_store_agent_prisma,
):
mock_slv_prisma.return_value.find_unique = AsyncMock(return_value=mock_slv)
result = await get_store_agent_details_as_admin(SLV_ID)
# Verify it queried StoreListingVersion (not the APPROVED-only StoreAgent)
mock_slv_prisma.return_value.find_unique.assert_awaited_once()
await_args = mock_slv_prisma.return_value.find_unique.await_args
assert await_args is not None
assert await_args.kwargs["where"] == {"id": SLV_ID}
# Verify the APPROVED-only StoreAgent view was NOT touched
mock_store_agent_prisma.assert_not_called()
# Verify the result has the right data
assert result.agent_name == "Test Agent"
assert result.agent_image == ["https://example.com/img.png"]
assert result.has_approved_version is False
assert result.runs == 0
assert result.rating == 0.0
@pytest.mark.asyncio
async def test_resolve_graph_admin_uses_get_graph_as_admin() -> None:
"""resolve_graph_for_library(admin=True) must call get_graph_as_admin,
not get_graph. This is THE test that prevents the add-to-library bypass
from being accidentally reverted."""
from backend.api.features.library._add_to_library import resolve_graph_for_library
mock_slv = MagicMock()
mock_slv.AgentGraph = MagicMock(id=GRAPH_ID, version=GRAPH_VERSION)
mock_graph_model = MagicMock(name="GraphModel")
with (
patch(
"backend.api.features.library._add_to_library.prisma.models"
".StoreListingVersion.prisma",
) as mock_prisma,
patch(
"backend.api.features.library._add_to_library.graph_db"
".get_graph_as_admin",
new_callable=AsyncMock,
return_value=mock_graph_model,
) as mock_admin,
patch(
"backend.api.features.library._add_to_library.graph_db.get_graph",
new_callable=AsyncMock,
) as mock_regular,
):
mock_prisma.return_value.find_unique = AsyncMock(return_value=mock_slv)
result = await resolve_graph_for_library(SLV_ID, ADMIN_USER_ID, admin=True)
assert result is mock_graph_model
mock_admin.assert_awaited_once_with(
graph_id=GRAPH_ID, version=GRAPH_VERSION, user_id=ADMIN_USER_ID
)
mock_regular.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_graph_regular_uses_get_graph() -> None:
"""resolve_graph_for_library(admin=False) must call get_graph,
not get_graph_as_admin. Ensures the non-admin path is preserved."""
from backend.api.features.library._add_to_library import resolve_graph_for_library
mock_slv = MagicMock()
mock_slv.AgentGraph = MagicMock(id=GRAPH_ID, version=GRAPH_VERSION)
mock_graph_model = MagicMock(name="GraphModel")
with (
patch(
"backend.api.features.library._add_to_library.prisma.models"
".StoreListingVersion.prisma",
) as mock_prisma,
patch(
"backend.api.features.library._add_to_library.graph_db"
".get_graph_as_admin",
new_callable=AsyncMock,
) as mock_admin,
patch(
"backend.api.features.library._add_to_library.graph_db.get_graph",
new_callable=AsyncMock,
return_value=mock_graph_model,
) as mock_regular,
):
mock_prisma.return_value.find_unique = AsyncMock(return_value=mock_slv)
result = await resolve_graph_for_library(SLV_ID, "regular-user-id", admin=False)
assert result is mock_graph_model
mock_regular.assert_awaited_once_with(
graph_id=GRAPH_ID, version=GRAPH_VERSION, user_id="regular-user-id"
)
mock_admin.assert_not_awaited()
# ---- Library membership grants graph access (product decision) ---- #
@pytest.mark.asyncio
async def test_library_member_can_view_pending_agent_in_builder() -> None:
"""After adding a pending agent to their library, the user should be
able to load the graph in the builder via get_graph()."""
mock_graph = _make_mock_graph()
mock_graph_model = MagicMock(name="GraphModel")
mock_library_agent = MagicMock()
mock_library_agent.AgentGraph = mock_graph
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
),
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_lib_prisma.return_value.find_first = AsyncMock(
return_value=mock_library_agent
)
from backend.data.graph import get_graph
result = await get_graph(
graph_id=GRAPH_ID,
version=GRAPH_VERSION,
user_id=ADMIN_USER_ID,
)
assert result is mock_graph_model, "Library membership should grant graph access"

View File

@@ -1,124 +0,0 @@
"""Shared logic for adding store agents to a user's library.
Both `add_store_agent_to_library` and `add_store_agent_to_library_as_admin`
delegate to these helpers so the duplication-prone create/restore/dedup
logic lives in exactly one place.
"""
import logging
import prisma.errors
import prisma.models
import backend.api.features.library.model as library_model
import backend.data.graph as graph_db
from backend.data.graph import GraphModel, GraphSettings
from backend.data.includes import library_agent_include
from backend.util.exceptions import NotFoundError
from backend.util.json import SafeJson
from .db import get_library_agent_by_graph_id, update_library_agent
logger = logging.getLogger(__name__)
async def resolve_graph_for_library(
store_listing_version_id: str,
user_id: str,
*,
admin: bool,
) -> GraphModel:
"""Look up a StoreListingVersion and resolve its graph.
When ``admin=True``, uses ``get_graph_as_admin`` to bypass the marketplace
APPROVED-only check. Otherwise uses the regular ``get_graph``.
"""
slv = await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"AgentGraph": True}
)
if not slv or not slv.AgentGraph:
raise NotFoundError(
f"Store listing version {store_listing_version_id} not found or invalid"
)
ag = slv.AgentGraph
if admin:
graph_model = await graph_db.get_graph_as_admin(
graph_id=ag.id, version=ag.version, user_id=user_id
)
else:
graph_model = await graph_db.get_graph(
graph_id=ag.id, version=ag.version, user_id=user_id
)
if not graph_model:
raise NotFoundError(f"Graph #{ag.id} v{ag.version} not found or accessible")
return graph_model
async def add_graph_to_library(
store_listing_version_id: str,
graph_model: GraphModel,
user_id: str,
) -> library_model.LibraryAgent:
"""Check existing / restore soft-deleted / create new LibraryAgent."""
if existing := await get_library_agent_by_graph_id(
user_id, graph_model.id, graph_model.version
):
return existing
deleted_agent = await prisma.models.LibraryAgent.prisma().find_unique(
where={
"userId_agentGraphId_agentGraphVersion": {
"userId": user_id,
"agentGraphId": graph_model.id,
"agentGraphVersion": graph_model.version,
}
},
)
if deleted_agent and (deleted_agent.isDeleted or deleted_agent.isArchived):
return await update_library_agent(
deleted_agent.id,
user_id,
is_deleted=False,
is_archived=False,
)
try:
added_agent = await prisma.models.LibraryAgent.prisma().create(
data={
"User": {"connect": {"id": user_id}},
"AgentGraph": {
"connect": {
"graphVersionId": {
"id": graph_model.id,
"version": graph_model.version,
}
}
},
"isCreatedByUser": False,
"useGraphIsActiveVersion": False,
"settings": SafeJson(
GraphSettings.from_graph(graph_model).model_dump()
),
},
include=library_agent_include(
user_id, include_nodes=False, include_executions=False
),
)
except prisma.errors.UniqueViolationError:
# Race condition: concurrent request created the row between our
# check and create. Re-read instead of crashing.
existing = await get_library_agent_by_graph_id(
user_id, graph_model.id, graph_model.version
)
if existing:
return existing
raise # Shouldn't happen, but don't swallow unexpected errors
logger.debug(
f"Added graph #{graph_model.id} v{graph_model.version} "
f"for store listing version #{store_listing_version_id} "
f"to library for user #{user_id}"
)
return library_model.LibraryAgent.from_db(added_agent)

View File

@@ -1,71 +0,0 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from ._add_to_library import add_graph_to_library
@pytest.mark.asyncio
async def test_add_graph_to_library_restores_archived_agent() -> None:
graph_model = MagicMock(id="graph-id", version=2)
archived_agent = MagicMock(id="library-agent-id", isDeleted=False, isArchived=True)
restored_agent = MagicMock(name="LibraryAgentModel")
with (
patch(
"backend.api.features.library._add_to_library.get_library_agent_by_graph_id",
new=AsyncMock(return_value=None),
),
patch(
"backend.api.features.library._add_to_library.prisma.models.LibraryAgent.prisma"
) as mock_prisma,
patch(
"backend.api.features.library._add_to_library.update_library_agent",
new=AsyncMock(return_value=restored_agent),
) as mock_update,
):
mock_prisma.return_value.find_unique = AsyncMock(return_value=archived_agent)
result = await add_graph_to_library("slv-id", graph_model, "user-id")
assert result is restored_agent
mock_update.assert_awaited_once_with(
"library-agent-id",
"user-id",
is_deleted=False,
is_archived=False,
)
mock_prisma.return_value.create.assert_not_called()
@pytest.mark.asyncio
async def test_add_graph_to_library_restores_deleted_agent() -> None:
graph_model = MagicMock(id="graph-id", version=2)
deleted_agent = MagicMock(id="library-agent-id", isDeleted=True, isArchived=False)
restored_agent = MagicMock(name="LibraryAgentModel")
with (
patch(
"backend.api.features.library._add_to_library.get_library_agent_by_graph_id",
new=AsyncMock(return_value=None),
),
patch(
"backend.api.features.library._add_to_library.prisma.models.LibraryAgent.prisma"
) as mock_prisma,
patch(
"backend.api.features.library._add_to_library.update_library_agent",
new=AsyncMock(return_value=restored_agent),
) as mock_update,
):
mock_prisma.return_value.find_unique = AsyncMock(return_value=deleted_agent)
result = await add_graph_to_library("slv-id", graph_model, "user-id")
assert result is restored_agent
mock_update.assert_awaited_once_with(
"library-agent-id",
"user-id",
is_deleted=False,
is_archived=False,
)
mock_prisma.return_value.create.assert_not_called()

View File

@@ -336,15 +336,12 @@ async def get_library_agent_by_graph_id(
user_id: str,
graph_id: str,
graph_version: Optional[int] = None,
include_archived: bool = False,
) -> library_model.LibraryAgent | None:
filter: prisma.types.LibraryAgentWhereInput = {
"agentGraphId": graph_id,
"userId": user_id,
"isDeleted": False,
}
if not include_archived:
filter["isArchived"] = False
if graph_version is not None:
filter["agentGraphVersion"] = graph_version
@@ -585,9 +582,7 @@ async def update_graph_in_library(
created_graph = await graph_db.create_graph(graph_model, user_id)
library_agent = await get_library_agent_by_graph_id(
user_id, created_graph.id, include_archived=True
)
library_agent = await get_library_agent_by_graph_id(user_id, created_graph.id)
if not library_agent:
raise NotFoundError(f"Library agent not found for graph {created_graph.id}")
@@ -823,38 +818,92 @@ async def delete_library_agent_by_graph_id(graph_id: str, user_id: str) -> None:
async def add_store_agent_to_library(
store_listing_version_id: str, user_id: str
) -> library_model.LibraryAgent:
"""Adds a marketplace agent to the users library.
See also: `add_store_agent_to_library_as_admin()` which uses
`get_graph_as_admin` to bypass marketplace status checks for admin review.
"""
from ._add_to_library import add_graph_to_library, resolve_graph_for_library
Adds an agent from a store listing version to the user's library if they don't already have it.
Args:
store_listing_version_id: The ID of the store listing version containing the agent.
user_id: The users library to which the agent is being added.
Returns:
The newly created LibraryAgent if successfully added, the existing corresponding one if any.
Raises:
NotFoundError: If the store listing or associated agent is not found.
DatabaseError: If there's an issue creating the LibraryAgent record.
"""
logger.debug(
f"Adding agent from store listing version #{store_listing_version_id} "
f"to library for user #{user_id}"
)
graph_model = await resolve_graph_for_library(
store_listing_version_id, user_id, admin=False
)
return await add_graph_to_library(store_listing_version_id, graph_model, user_id)
async def add_store_agent_to_library_as_admin(
store_listing_version_id: str, user_id: str
) -> library_model.LibraryAgent:
"""Admin variant that uses `get_graph_as_admin` to bypass marketplace
APPROVED-only checks, allowing admins to add pending agents for review."""
from ._add_to_library import add_graph_to_library, resolve_graph_for_library
logger.warning(
f"ADMIN adding agent from store listing version "
f"#{store_listing_version_id} to library for user #{user_id}"
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"AgentGraph": True}
)
)
graph_model = await resolve_graph_for_library(
store_listing_version_id, user_id, admin=True
if not store_listing_version or not store_listing_version.AgentGraph:
logger.warning(f"Store listing version not found: {store_listing_version_id}")
raise NotFoundError(
f"Store listing version {store_listing_version_id} not found or invalid"
)
graph = store_listing_version.AgentGraph
# Convert to GraphModel to check for HITL blocks
graph_model = await graph_db.get_graph(
graph_id=graph.id,
version=graph.version,
user_id=user_id,
include_subgraphs=False,
)
return await add_graph_to_library(store_listing_version_id, graph_model, user_id)
if not graph_model:
raise NotFoundError(
f"Graph #{graph.id} v{graph.version} not found or accessible"
)
# Check if user already has this agent (non-deleted)
if existing := await get_library_agent_by_graph_id(
user_id, graph.id, graph.version
):
return existing
# Check for soft-deleted version and restore it
deleted_agent = await prisma.models.LibraryAgent.prisma().find_unique(
where={
"userId_agentGraphId_agentGraphVersion": {
"userId": user_id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
}
},
)
if deleted_agent and deleted_agent.isDeleted:
return await update_library_agent(deleted_agent.id, user_id, is_deleted=False)
# Create LibraryAgent entry
added_agent = await prisma.models.LibraryAgent.prisma().create(
data={
"User": {"connect": {"id": user_id}},
"AgentGraph": {
"connect": {
"graphVersionId": {"id": graph.id, "version": graph.version}
}
},
"isCreatedByUser": False,
"useGraphIsActiveVersion": False,
"settings": SafeJson(GraphSettings.from_graph(graph_model).model_dump()),
},
include=library_agent_include(
user_id, include_nodes=False, include_executions=False
),
)
logger.debug(
f"Added graph #{graph.id} v{graph.version}"
f"for store listing version #{store_listing_version.id} "
f"to library for user #{user_id}"
)
return library_model.LibraryAgent.from_db(added_agent)
##############################################

View File

@@ -150,13 +150,8 @@ async def test_add_agent_to_library(mocker):
)
# Mock graph_db.get_graph function that's called to check for HITL blocks
# (lives in _add_to_library.py after refactor, not db.py)
mock_graph_db = mocker.patch(
"backend.api.features.library._add_to_library.graph_db"
)
mock_graph_db = mocker.patch("backend.api.features.library.db.graph_db")
mock_graph_model = mocker.Mock()
mock_graph_model.id = "agent1"
mock_graph_model.version = 1
mock_graph_model.nodes = (
[]
) # Empty list so _has_human_in_the_loop_blocks returns False
@@ -229,94 +224,3 @@ async def test_add_agent_to_library_not_found(mocker):
mock_store_listing_version.return_value.find_unique.assert_called_once_with(
where={"id": "version123"}, include={"AgentGraph": True}
)
@pytest.mark.asyncio
async def test_get_library_agent_by_graph_id_excludes_archived(mocker):
mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma")
mock_library_agent.return_value.find_first = mocker.AsyncMock(return_value=None)
result = await db.get_library_agent_by_graph_id("test-user", "agent1", 7)
assert result is None
mock_library_agent.return_value.find_first.assert_called_once()
where = mock_library_agent.return_value.find_first.call_args.kwargs["where"]
assert where == {
"agentGraphId": "agent1",
"userId": "test-user",
"isDeleted": False,
"isArchived": False,
"agentGraphVersion": 7,
}
@pytest.mark.asyncio
async def test_get_library_agent_by_graph_id_can_include_archived(mocker):
mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma")
mock_library_agent.return_value.find_first = mocker.AsyncMock(return_value=None)
result = await db.get_library_agent_by_graph_id(
"test-user",
"agent1",
7,
include_archived=True,
)
assert result is None
mock_library_agent.return_value.find_first.assert_called_once()
where = mock_library_agent.return_value.find_first.call_args.kwargs["where"]
assert where == {
"agentGraphId": "agent1",
"userId": "test-user",
"isDeleted": False,
"agentGraphVersion": 7,
}
@pytest.mark.asyncio
async def test_update_graph_in_library_allows_archived_library_agent(mocker):
graph = mocker.Mock(id="graph-id")
existing_version = mocker.Mock(version=1, is_active=True)
graph_model = mocker.Mock()
created_graph = mocker.Mock(id="graph-id", version=2, is_active=False)
current_library_agent = mocker.Mock()
updated_library_agent = mocker.Mock()
mocker.patch(
"backend.api.features.library.db.graph_db.get_graph_all_versions",
new=mocker.AsyncMock(return_value=[existing_version]),
)
mocker.patch(
"backend.api.features.library.db.graph_db.make_graph_model",
return_value=graph_model,
)
mocker.patch(
"backend.api.features.library.db.graph_db.create_graph",
new=mocker.AsyncMock(return_value=created_graph),
)
mock_get_library_agent = mocker.patch(
"backend.api.features.library.db.get_library_agent_by_graph_id",
new=mocker.AsyncMock(return_value=current_library_agent),
)
mock_update_library_agent = mocker.patch(
"backend.api.features.library.db.update_library_agent_version_and_settings",
new=mocker.AsyncMock(return_value=updated_library_agent),
)
result_graph, result_library_agent = await db.update_graph_in_library(
graph,
"test-user",
)
assert result_graph is created_graph
assert result_library_agent is updated_library_agent
assert graph.version == 2
graph_model.reassign_ids.assert_called_once_with(
user_id="test-user", reassign_graph_id=False
)
mock_get_library_agent.assert_awaited_once_with(
"test-user",
"graph-id",
include_archived=True,
)
mock_update_library_agent.assert_awaited_once_with("test-user", created_graph)

View File

@@ -391,11 +391,6 @@ async def get_available_graph(
async def get_store_agent_by_version_id(
store_listing_version_id: str,
) -> store_model.StoreAgentDetails:
"""Get agent details from the StoreAgent view (APPROVED agents only).
See also: `get_store_agent_details_as_admin()` which bypasses the
APPROVED-only StoreAgent view for admin preview of pending submissions.
"""
logger.debug(f"Getting store agent details for {store_listing_version_id}")
try:
@@ -416,57 +411,6 @@ async def get_store_agent_by_version_id(
raise DatabaseError("Failed to fetch agent details") from e
async def get_store_agent_details_as_admin(
store_listing_version_id: str,
) -> store_model.StoreAgentDetails:
"""Get agent details for admin preview, bypassing the APPROVED-only
StoreAgent view. Queries StoreListingVersion directly so pending
submissions are visible."""
slv = await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id},
include={
"StoreListing": {"include": {"CreatorProfile": True}},
},
)
if not slv or not slv.StoreListing:
raise NotFoundError(
f"Store listing version {store_listing_version_id} not found"
)
listing = slv.StoreListing
# CreatorProfile is a required FK relation — should always exist.
# If it's None, the DB is in a bad state.
profile = listing.CreatorProfile
if not profile:
raise DatabaseError(
f"StoreListing {listing.id} has no CreatorProfile — FK violated"
)
return store_model.StoreAgentDetails(
store_listing_version_id=slv.id,
slug=listing.slug,
agent_name=slv.name,
agent_video=slv.videoUrl or "",
agent_output_demo=slv.agentOutputDemoUrl or "",
agent_image=slv.imageUrls,
creator=profile.username,
creator_avatar=profile.avatarUrl or "",
sub_heading=slv.subHeading,
description=slv.description,
instructions=slv.instructions,
categories=slv.categories,
runs=0,
rating=0.0,
versions=[str(slv.version)],
graph_id=slv.agentGraphId,
graph_versions=[str(slv.agentGraphVersion)],
last_updated=slv.updatedAt,
recommended_schedule_cron=slv.recommendedScheduleCron,
active_version_id=listing.activeVersionId or slv.id,
has_approved_version=listing.hasApprovedVersion,
)
class StoreCreatorsSortOptions(Enum):
# NOTE: values correspond 1:1 to columns of the Creator view
AGENT_RATING = "agent_rating"

View File

@@ -1,5 +1,6 @@
import asyncio
import base64
import re
from abc import ABC
from email import encoders
from email.mime.base import MIMEBase
@@ -8,7 +9,7 @@ from email.mime.text import MIMEText
from email.policy import SMTP
from email.utils import getaddresses, parseaddr
from pathlib import Path
from typing import List, Literal, Optional
from typing import List, Literal, Optional, Protocol, runtime_checkable
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
@@ -42,8 +43,47 @@ NO_WRAP_POLICY = SMTP.clone(max_line_length=0)
def serialize_email_recipients(recipients: list[str]) -> str:
"""Serialize recipients list to comma-separated string."""
return ", ".join(recipients)
"""Serialize recipients list to comma-separated string.
Strips leading/trailing whitespace from each address to keep MIME
headers clean (mirrors the strip done in ``validate_email_recipients``).
"""
return ", ".join(addr.strip() for addr in recipients)
# RFC 5322 simplified pattern: local@domain where domain has at least one dot
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@runtime_checkable
class HasRecipients(Protocol):
to: list[str]
cc: list[str]
bcc: list[str]
def validate_all_recipients(input_data: HasRecipients) -> None:
"""Validate to/cc/bcc recipient lists on the given input data."""
validate_email_recipients(input_data.to, "to")
if input_data.cc:
validate_email_recipients(input_data.cc, "cc")
if input_data.bcc:
validate_email_recipients(input_data.bcc, "bcc")
def validate_email_recipients(recipients: list[str], field_name: str = "to") -> None:
"""Validate that all recipients are plausible email addresses.
Raises ``ValueError`` with a user-friendly message listing every
invalid entry so the caller (or LLM) can correct them in one pass.
"""
invalid = [addr for addr in recipients if not _EMAIL_RE.match(addr.strip())]
if invalid:
formatted = ", ".join(f"'{a}'" for a in invalid)
raise ValueError(
f"Invalid email address(es) in '{field_name}': {formatted}. "
f"Each entry must be a valid email address (e.g. user@example.com)."
)
def _make_mime_text(
@@ -100,14 +140,16 @@ async def create_mime_message(
) -> str:
"""Create a MIME message with attachments and return base64-encoded raw message."""
validate_all_recipients(input_data)
message = MIMEMultipart()
message["to"] = serialize_email_recipients(input_data.to)
message["subject"] = input_data.subject
if input_data.cc:
message["cc"] = ", ".join(input_data.cc)
message["cc"] = serialize_email_recipients(input_data.cc)
if input_data.bcc:
message["bcc"] = ", ".join(input_data.bcc)
message["bcc"] = serialize_email_recipients(input_data.bcc)
# Use the new helper function with content_type if available
content_type = getattr(input_data, "content_type", None)
@@ -1167,13 +1209,15 @@ async def _build_reply_message(
references.append(headers["message-id"])
# Create MIME message
validate_all_recipients(input_data)
msg = MIMEMultipart()
if input_data.to:
msg["To"] = ", ".join(input_data.to)
msg["To"] = serialize_email_recipients(input_data.to)
if input_data.cc:
msg["Cc"] = ", ".join(input_data.cc)
msg["Cc"] = serialize_email_recipients(input_data.cc)
if input_data.bcc:
msg["Bcc"] = ", ".join(input_data.bcc)
msg["Bcc"] = serialize_email_recipients(input_data.bcc)
msg["Subject"] = subject
if headers.get("message-id"):
msg["In-Reply-To"] = headers["message-id"]
@@ -1685,13 +1729,15 @@ To: {original_to}
else:
body = f"{forward_header}\n\n{original_body}"
validate_all_recipients(input_data)
# Create MIME message
msg = MIMEMultipart()
msg["To"] = ", ".join(input_data.to)
msg["To"] = serialize_email_recipients(input_data.to)
if input_data.cc:
msg["Cc"] = ", ".join(input_data.cc)
msg["Cc"] = serialize_email_recipients(input_data.cc)
if input_data.bcc:
msg["Bcc"] = ", ".join(input_data.bcc)
msg["Bcc"] = serialize_email_recipients(input_data.bcc)
msg["Subject"] = subject
# Add body with proper content type

View File

@@ -1096,9 +1096,6 @@ async def get_graph(
Retrieves a graph from the DB.
Defaults to the version with `is_active` if `version` is not passed.
See also: `get_graph_as_admin()` which bypasses ownership and marketplace
checks for admin-only routes.
Returns `None` if the record is not found.
"""
graph = None
@@ -1136,27 +1133,6 @@ async def get_graph(
):
graph = store_listing.AgentGraph
# Fall back to library membership: if the user has the agent in their
# library (non-deleted, non-archived), grant access even if the agent is
# no longer published. "You added it, you keep it."
if graph is None and user_id is not None:
library_where: dict[str, object] = {
"userId": user_id,
"agentGraphId": graph_id,
"isDeleted": False,
"isArchived": False,
}
if version is not None:
library_where["agentGraphVersion"] = version
library_agent = await LibraryAgent.prisma().find_first(
where=library_where,
include={"AgentGraph": {"include": AGENT_GRAPH_INCLUDE}},
order={"agentGraphVersion": "desc"},
)
if library_agent and library_agent.AgentGraph:
graph = library_agent.AgentGraph
if graph is None:
return None
@@ -1392,9 +1368,8 @@ async def validate_graph_execution_permissions(
## Logic
A user can execute a graph if any of these is true:
1. They own the graph and some version of it is still listed in their library
2. The graph is in the user's library (non-deleted, non-archived)
3. The graph is published in the marketplace and listed in their library
4. The graph is published in the marketplace and is being executed as a sub-agent
2. The graph is published in the marketplace and listed in their library
3. The graph is published in the marketplace and is being executed as a sub-agent
Args:
graph_id: The ID of the graph to check
@@ -1416,7 +1391,6 @@ async def validate_graph_execution_permissions(
where={
"userId": user_id,
"agentGraphId": graph_id,
"agentGraphVersion": graph_version,
"isDeleted": False,
"isArchived": False,
}
@@ -1426,39 +1400,19 @@ async def validate_graph_execution_permissions(
# Step 1: Check if user owns this graph
user_owns_graph = graph and graph.userId == user_id
# Step 2: Check if the exact graph version is in the library.
# Step 2: Check if agent is in the library *and not deleted*
user_has_in_library = library_agent is not None
owner_has_live_library_entry = user_has_in_library
if user_owns_graph and not user_has_in_library:
# Owners are allowed to execute a new version as long as some live
# library entry still exists for the graph. Non-owners stay
# version-specific.
owner_has_live_library_entry = (
await LibraryAgent.prisma().find_first(
where={
"userId": user_id,
"agentGraphId": graph_id,
"isDeleted": False,
"isArchived": False,
}
)
is not None
)
# Step 3: Apply permission logic
# Access is granted if the user owns it, it's in the marketplace, OR
# it's in the user's library ("you added it, you keep it").
if not (
user_owns_graph
or user_has_in_library
or await is_graph_published_in_marketplace(graph_id, graph_version)
):
raise GraphNotAccessibleError(
f"You do not have access to graph #{graph_id} v{graph_version}: "
"it is not owned by you, not in your library, "
"and not available in the Marketplace"
"it is not owned by you and not available in the Marketplace"
)
elif not (user_has_in_library or owner_has_live_library_entry or is_sub_graph):
elif not (user_has_in_library or is_sub_graph):
raise GraphNotInLibraryError(f"Graph #{graph_id} is not in your library")
# Step 6: Check execution-specific permissions (raises generic NotAuthorizedError)

View File

@@ -13,17 +13,10 @@ from backend.api.model import CreateGraph
from backend.blocks._base import BlockSchema, BlockSchemaInput
from backend.blocks.basic import StoreValueBlock
from backend.blocks.io import AgentInputBlock, AgentOutputBlock
from backend.data.graph import (
Graph,
Link,
Node,
get_graph,
validate_graph_execution_permissions,
)
from backend.data.graph import Graph, Link, Node, get_graph
from backend.data.model import SchemaField
from backend.data.user import DEFAULT_USER_ID
from backend.usecases.sample import create_test_user
from backend.util.exceptions import GraphNotAccessibleError, GraphNotInLibraryError
from backend.util.test import SpinTestServer
@@ -604,32 +597,9 @@ def test_mcp_credential_combine_no_discriminator_values():
)
# --------------- get_graph access-control truth table --------------- #
#
# Full matrix of access scenarios for get_graph() and get_graph_as_admin().
# Access priority: ownership > marketplace APPROVED > library membership.
# Library is version-specific. get_graph_as_admin bypasses everything.
#
# | User | Owns? | Marketplace | Library | Version | Result | Test
# |----------|-------|-------------|------------------|---------|---------|-----
# | regular | yes | any | any | v1 | ACCESS | test_get_graph_library_not_queried_when_owned
# | regular | no | APPROVED | any | v1 | ACCESS | test_get_graph_non_owner_approved_marketplace_agent
# | regular | no | not listed | active, same ver | v1 | ACCESS | test_get_graph_library_member_can_access_unpublished
# | regular | no | not listed | active, diff ver | v2 | DENIED | test_get_graph_library_wrong_version_denied
# | regular | no | not listed | deleted | v1 | DENIED | test_get_graph_deleted_library_agent_denied
# | regular | no | not listed | archived | v1 | DENIED | test_get_graph_archived_library_agent_denied
# | regular | no | not listed | not present | v1 | DENIED | test_get_graph_non_owner_pending_not_in_library_denied
# | regular | no | PENDING | active v1 | v2 | DENIED | test_library_v1_does_not_grant_access_to_pending_v2
# | regular | no | not listed | null AgentGraph | v1 | DENIED | test_get_graph_library_with_null_agent_graph_denied
# | anon | no | not listed | - | v1 | DENIED | test_get_graph_library_fallback_not_used_for_anonymous
# | anon | no | APPROVED | - | v1 | ACCESS | test_get_graph_anonymous_approved_marketplace_access
# | admin* | no | PENDING | - | v2 | ACCESS | test_admin_can_access_pending_v2_via_get_graph_as_admin
#
# Efficiency (no unnecessary queries):
# | regular | yes | - | - | v1 | no mkt/lib | test_get_graph_library_not_queried_when_owned
# | regular | no | APPROVED | - | v1 | no lib | test_get_graph_library_not_queried_when_marketplace_approved
#
# * = via get_graph_as_admin (admin-only routes)
# --------------- get_graph access-control regression tests --------------- #
# These protect the behavior introduced in PR #11323 (Reinier, 2025-11-05):
# non-owners can access APPROVED marketplace agents but NOT pending ones.
def _make_mock_db_graph(user_id: str = "owner-user-id") -> MagicMock:
@@ -679,9 +649,10 @@ async def test_get_graph_non_owner_approved_marketplace_agent() -> None:
@pytest.mark.asyncio
async def test_get_graph_non_owner_pending_not_in_library_denied() -> None:
"""A non-owner with no library membership and no APPROVED marketplace
listing must be denied access."""
async def test_get_graph_non_owner_pending_marketplace_agent_denied() -> None:
"""A non-owner must NOT be able to access a graph that only has a PENDING
(not APPROVED) marketplace listing. The marketplace fallback filters on
submissionStatus=APPROVED, so pending agents should be invisible."""
requester_id = "different-user-id"
graph_id = "graph-id"
@@ -690,773 +661,16 @@ async def test_get_graph_non_owner_pending_not_in_library_denied() -> None:
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=requester_id,
)
assert (
result is None
), "User without ownership, marketplace, or library access must be denied"
# --------------- Library membership grants graph access --------------- #
# "You added it, you keep it" — product decision from SECRT-2167.
@pytest.mark.asyncio
async def test_get_graph_library_member_can_access_unpublished() -> None:
"""A user who has the agent in their library should be able to access it
even if it's no longer published in the marketplace."""
requester_id = "library-user-id"
graph_id = "graph-id"
mock_graph = _make_mock_db_graph("original-creator-id")
mock_graph_model = MagicMock(name="GraphModel")
mock_library_agent = MagicMock()
mock_library_agent.AgentGraph = mock_graph
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
),
):
# Not owned
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
# Not in marketplace (unpublished)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
# But IS in user's library
mock_lib_prisma.return_value.find_first = AsyncMock(
return_value=mock_library_agent
)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=requester_id,
)
assert result is mock_graph_model, "Library member should access unpublished agent"
# Verify library query filters on non-deleted, non-archived
lib_call = mock_lib_prisma.return_value.find_first
lib_call.assert_awaited_once()
assert lib_call.await_args is not None
lib_where = lib_call.await_args.kwargs["where"]
assert lib_where["userId"] == requester_id
assert lib_where["agentGraphId"] == graph_id
assert lib_where["isDeleted"] is False
assert lib_where["isArchived"] is False
@pytest.mark.asyncio
async def test_get_graph_deleted_library_agent_denied() -> None:
"""If the user soft-deleted the agent from their library, they should
NOT get access via the library fallback."""
requester_id = "library-user-id"
graph_id = "graph-id"
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
# Library query returns None because isDeleted=False filter excludes it
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=requester_id,
)
assert result is None, "Deleted library agent should not grant graph access"
@pytest.mark.asyncio
async def test_get_graph_anonymous_approved_marketplace_access() -> None:
"""Anonymous users (user_id=None) should still access APPROVED marketplace
agents — the marketplace fallback doesn't require authentication."""
graph_id = "graph-id"
mock_graph = _make_mock_db_graph("creator-id")
mock_graph_model = MagicMock(name="GraphModel")
mock_listing = MagicMock()
mock_listing.AgentGraph = mock_graph
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
),
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=mock_listing)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=None,
)
assert (
result is mock_graph_model
), "Anonymous user should access APPROVED marketplace agent"
@pytest.mark.asyncio
async def test_get_graph_library_fallback_not_used_for_anonymous() -> None:
"""Anonymous requests (user_id=None) must not trigger the library
fallback — there's no user to check library membership for."""
graph_id = "graph-id"
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
# First lookup (owned graph) returns None
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
# Marketplace fallback finds nothing (not APPROVED)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=None,
)
assert result is None
# Library should never be queried for anonymous users
mock_lib_prisma.return_value.find_first.assert_not_called()
@pytest.mark.asyncio
async def test_get_graph_library_not_queried_when_owned() -> None:
"""If the user owns the graph, the library fallback should NOT be
triggered — ownership is sufficient."""
owner_id = "owner-user-id"
graph_id = "graph-id"
mock_graph = _make_mock_db_graph(owner_id)
mock_graph_model = MagicMock(name="GraphModel")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
),
):
# User owns the graph — first lookup succeeds
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=mock_graph)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=owner_id,
)
assert result is mock_graph_model
# Neither marketplace nor library should be queried
mock_slv_prisma.return_value.find_first.assert_not_called()
mock_lib_prisma.return_value.find_first.assert_not_called()
@pytest.mark.asyncio
async def test_get_graph_library_not_queried_when_marketplace_approved() -> None:
"""If the graph is APPROVED in the marketplace, the library fallback
should NOT be triggered — marketplace access is sufficient."""
requester_id = "different-user-id"
graph_id = "graph-id"
mock_graph = _make_mock_db_graph("original-creator-id")
mock_graph_model = MagicMock(name="GraphModel")
mock_listing = MagicMock()
mock_listing.AgentGraph = mock_graph
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
),
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=mock_listing)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=requester_id,
)
assert result is mock_graph_model
# Library should not be queried — marketplace was sufficient
mock_lib_prisma.return_value.find_first.assert_not_called()
@pytest.mark.asyncio
async def test_get_graph_archived_library_agent_denied() -> None:
"""If the user archived the agent in their library, they should
NOT get access via the library fallback."""
requester_id = "library-user-id"
graph_id = "graph-id"
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
# Library query returns None because isArchived=False filter excludes it
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=requester_id,
)
assert result is None, "Archived library agent should not grant graph access"
@pytest.mark.asyncio
async def test_get_graph_library_with_null_agent_graph_denied() -> None:
"""If LibraryAgent exists but its AgentGraph relation is None
(data integrity issue), access must be denied, not crash."""
requester_id = "library-user-id"
graph_id = "graph-id"
mock_library_agent = MagicMock()
mock_library_agent.AgentGraph = None # broken relation
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_lib_prisma.return_value.find_first = AsyncMock(
return_value=mock_library_agent
)
result = await get_graph(
graph_id=graph_id,
version=1,
user_id=requester_id,
)
assert (
result is None
), "Library agent with missing graph relation should not grant access"
@pytest.mark.asyncio
async def test_get_graph_library_wrong_version_denied() -> None:
"""Having version 1 in your library must NOT grant access to version 2."""
requester_id = "library-user-id"
graph_id = "graph-id"
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
# Library has version 1 but we're requesting version 2 —
# the where clause includes agentGraphVersion so this returns None
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
result = await get_graph(
graph_id=graph_id,
version=2,
user_id=requester_id,
)
assert (
result is None
), "Library agent for version 1 must not grant access to version 2"
# Verify version was included in the library query
lib_call = mock_lib_prisma.return_value.find_first
lib_call.assert_called_once()
lib_where = lib_call.call_args.kwargs["where"]
assert lib_where["agentGraphVersion"] == 2
@pytest.mark.asyncio
async def test_library_v1_does_not_grant_access_to_pending_v2() -> None:
"""A regular user has v1 in their library. v2 is pending (not approved).
They must NOT get access to v2 — library membership is version-specific."""
requester_id = "regular-user-id"
graph_id = "graph-id"
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch(
"backend.data.graph.StoreListingVersion.prisma",
) as mock_slv_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
):
# Not owned
mock_ag_prisma.return_value.find_first = AsyncMock(return_value=None)
# v2 is not APPROVED in marketplace
mock_slv_prisma.return_value.find_first = AsyncMock(return_value=None)
# Library has v1 but not v2 — version filter excludes it
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
result = await get_graph(
graph_id=graph_id,
version=2,
user_id=requester_id,
)
assert result is None, "Regular user with v1 in library must not access pending v2"
@pytest.mark.asyncio
async def test_admin_can_access_pending_v2_via_get_graph_as_admin() -> None:
"""An admin can access v2 (pending) via get_graph_as_admin even though
only v1 is approved. get_graph_as_admin bypasses all access checks."""
from backend.data.graph import get_graph_as_admin
admin_id = "admin-user-id"
mock_graph = _make_mock_db_graph("creator-user-id")
mock_graph.version = 2
mock_graph_model = MagicMock(name="GraphModel")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_prisma,
patch(
"backend.data.graph.GraphModel.from_db",
return_value=mock_graph_model,
),
):
mock_prisma.return_value.find_first = AsyncMock(return_value=mock_graph)
result = await get_graph_as_admin(
graph_id="graph-id",
version=2,
user_id=admin_id,
for_export=False,
)
assert (
result is mock_graph_model
), "Admin must access pending v2 via get_graph_as_admin"
# --------------- execution permission truth table --------------- #
#
# validate_graph_execution_permissions() has two gates:
# 1. Accessible graph: owner OR exact-version library entry OR marketplace-published
# 2. Runnable graph: exact-version library entry OR owner fallback to any live
# library entry for the graph OR sub-graph exception
#
# Desired owner behavior differs from non-owners:
# owners should be allowed to run a new version when some non-archived/non-deleted
# version of that graph is still in their library. Non-owners stay
# version-specific.
#
# | User | Owns? | Marketplace | Library state | is_sub_graph | Result | Test
# |----------|-------|-------------|------------------------------|--------------|----------|-----
# | regular | no | no | exact version present | false | ALLOW | test_validate_graph_execution_permissions_library_member_same_version_allowed
# | owner | yes | no | exact version present | false | ALLOW | test_validate_graph_execution_permissions_owner_same_version_in_library_allowed
# | owner | yes | no | previous version present | false | ALLOW | test_validate_graph_execution_permissions_owner_previous_library_version_allowed
# | owner | yes | no | none present | false | DENY lib | test_validate_graph_execution_permissions_owner_without_library_denied
# | owner | yes | no | only archived/deleted older | false | DENY lib | test_validate_graph_execution_permissions_owner_previous_archived_library_version_denied
# | regular | no | yes | none present | false | DENY lib | test_validate_graph_execution_permissions_marketplace_graph_not_in_library_denied
# | admin | no | no | none present | false | DENY acc | test_validate_graph_execution_permissions_admin_without_library_or_marketplace_denied
# | regular | no | yes | none present | true | ALLOW | test_validate_graph_execution_permissions_marketplace_sub_graph_without_library_allowed
# | regular | no | no | none present | true | DENY acc | test_validate_graph_execution_permissions_unpublished_sub_graph_without_library_denied
# | regular | no | no | wrong version only | false | DENY acc | test_validate_graph_execution_permissions_library_wrong_version_denied
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_library_member_same_version_allowed() -> (
None
):
requester_id = "library-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId="creator-user-id")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=MagicMock())
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_not_awaited()
lib_where = mock_lib_prisma.return_value.find_first.call_args.kwargs["where"]
assert lib_where["agentGraphVersion"] == graph_version
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_owner_same_version_in_library_allowed() -> (
None
):
requester_id = "owner-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId=requester_id)
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=MagicMock())
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_not_awaited()
lib_where = mock_lib_prisma.return_value.find_first.call_args.kwargs["where"]
assert lib_where["agentGraphVersion"] == graph_version
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_owner_previous_library_version_allowed() -> (
None
):
requester_id = "owner-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId=requester_id)
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(
side_effect=[None, MagicMock(name="PriorVersionLibraryAgent")]
)
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_not_awaited()
assert mock_lib_prisma.return_value.find_first.await_count == 2
first_where = mock_lib_prisma.return_value.find_first.await_args_list[0].kwargs[
"where"
]
second_where = mock_lib_prisma.return_value.find_first.await_args_list[1].kwargs[
"where"
]
assert first_where["agentGraphVersion"] == graph_version
assert "agentGraphVersion" not in second_where
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_owner_without_library_denied() -> (
None
):
requester_id = "owner-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId=requester_id)
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
with pytest.raises(GraphNotInLibraryError):
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_not_awaited()
assert mock_lib_prisma.return_value.find_first.await_count == 2
first_where = mock_lib_prisma.return_value.find_first.await_args_list[0].kwargs[
"where"
]
second_where = mock_lib_prisma.return_value.find_first.await_args_list[1].kwargs[
"where"
]
assert first_where["agentGraphVersion"] == graph_version
assert second_where == {
"userId": requester_id,
"agentGraphId": graph_id,
"isDeleted": False,
"isArchived": False,
}
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_owner_previous_archived_library_version_denied() -> (
None
):
requester_id = "owner-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId=requester_id)
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(side_effect=[None, None])
with pytest.raises(GraphNotInLibraryError):
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_not_awaited()
assert mock_lib_prisma.return_value.find_first.await_count == 2
first_where = mock_lib_prisma.return_value.find_first.await_args_list[0].kwargs[
"where"
]
second_where = mock_lib_prisma.return_value.find_first.await_args_list[1].kwargs[
"where"
]
assert first_where["agentGraphVersion"] == graph_version
assert second_where == {
"userId": requester_id,
"agentGraphId": graph_id,
"isDeleted": False,
"isArchived": False,
}
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_marketplace_graph_not_in_library_denied() -> (
None
):
requester_id = "marketplace-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId="creator-user-id")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=True,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
with pytest.raises(GraphNotInLibraryError):
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_awaited_once_with(graph_id, graph_version)
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_admin_without_library_or_marketplace_denied() -> (
None
):
requester_id = "admin-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId="creator-user-id")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
with pytest.raises(GraphNotAccessibleError):
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_awaited_once_with(graph_id, graph_version)
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_unpublished_sub_graph_without_library_denied() -> (
None
):
requester_id = "marketplace-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId="creator-user-id")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
with pytest.raises(GraphNotAccessibleError):
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
is_sub_graph=True,
)
mock_is_published.assert_awaited_once_with(graph_id, graph_version)
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_marketplace_sub_graph_without_library_allowed() -> (
None
):
requester_id = "marketplace-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId="creator-user-id")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=True,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
is_sub_graph=True,
)
mock_is_published.assert_awaited_once_with(graph_id, graph_version)
@pytest.mark.asyncio
async def test_validate_graph_execution_permissions_library_wrong_version_denied() -> (
None
):
requester_id = "library-user-id"
graph_id = "graph-id"
graph_version = 2
mock_graph = MagicMock(userId="creator-user-id")
with (
patch("backend.data.graph.AgentGraph.prisma") as mock_ag_prisma,
patch("backend.data.graph.LibraryAgent.prisma") as mock_lib_prisma,
patch(
"backend.data.graph.is_graph_published_in_marketplace",
new_callable=AsyncMock,
return_value=False,
) as mock_is_published,
):
mock_ag_prisma.return_value.find_unique = AsyncMock(return_value=mock_graph)
mock_lib_prisma.return_value.find_first = AsyncMock(return_value=None)
with pytest.raises(GraphNotAccessibleError):
await validate_graph_execution_permissions(
user_id=requester_id,
graph_id=graph_id,
graph_version=graph_version,
)
mock_is_published.assert_awaited_once_with(graph_id, graph_version)
lib_where = mock_lib_prisma.return_value.find_first.call_args.kwargs["where"]
assert lib_where["agentGraphVersion"] == graph_version
assert result is None, "Non-owner must not access a pending marketplace agent"

View File

@@ -9,7 +9,6 @@ from datetime import datetime, timezone
from typing import Optional
import pydantic
from prisma.errors import UniqueViolationError
from prisma.models import UserWorkspace, UserWorkspaceFile
from prisma.types import UserWorkspaceFileWhereInput
@@ -76,23 +75,22 @@ async def get_or_create_workspace(user_id: str) -> Workspace:
"""
Get user's workspace, creating one if it doesn't exist.
Uses upsert to handle race conditions when multiple concurrent requests
attempt to create a workspace for the same user.
Args:
user_id: The user's ID
Returns:
Workspace instance
"""
workspace = await UserWorkspace.prisma().find_unique(where={"userId": user_id})
if workspace:
return Workspace.from_db(workspace)
try:
workspace = await UserWorkspace.prisma().create(data={"userId": user_id})
except UniqueViolationError:
# Concurrent request already created it
workspace = await UserWorkspace.prisma().find_unique(where={"userId": user_id})
if workspace is None:
raise
workspace = await UserWorkspace.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id},
"update": {}, # No updates needed if exists
},
)
return Workspace.from_db(workspace)

View File

@@ -165,10 +165,11 @@ def sanitize_json(data: Any) -> Any:
# Log the failure and fall back to string representation
logger.error(
"SafeJson fallback to string representation due to serialization error: %s (%s). "
"Data type: %s",
"Data type: %s, Data preview: %s",
type(e).__name__,
truncate(str(e), 200),
type(data).__name__,
truncate(str(data), 100),
)
# Ultimate fallback: convert to string representation and sanitize

View File

@@ -21,31 +21,6 @@ class DiscordChannel(str, Enum):
PRODUCT = "product" # For product alerts (low balance, zero balance, etc.)
_USER_AUTH_KEYWORDS = [
"incorrect api key",
"invalid x-api-key",
"invalid api key",
"missing authentication header",
"invalid api token",
"authentication_error",
"bad credentials",
"unauthorized",
"insufficient authentication scopes",
"http 401 error",
"http 403 error",
]
_AMQP_KEYWORDS = [
"amqpconnection",
"amqpconnector",
"connection_forced",
"channelinvalidstateerror",
"no active transport",
]
_AMQP_INDICATORS = ["aio_pika", "aiormq", "amqp", "pika", "rabbitmq"]
def _before_send(event, hint):
"""Filter out expected/transient errors from Sentry to reduce noise."""
if "exc_info" in hint:
@@ -53,21 +28,36 @@ def _before_send(event, hint):
exc_msg = str(exc_value).lower() if exc_value else ""
# AMQP/RabbitMQ transient connection errors — expected during deploys
if any(kw in exc_msg for kw in _AMQP_KEYWORDS):
amqp_keywords = [
"amqpconnection",
"amqpconnector",
"connection_forced",
"channelinvalidstateerror",
"no active transport",
]
if any(kw in exc_msg for kw in amqp_keywords):
return None
# "connection refused" only for AMQP-related exceptions (not other services)
if "connection refused" in exc_msg:
exc_module = getattr(exc_type, "__module__", "") or ""
exc_name = getattr(exc_type, "__name__", "") or ""
amqp_indicators = ["aio_pika", "aiormq", "amqp", "pika", "rabbitmq"]
if any(
ind in exc_module.lower() or ind in exc_name.lower()
for ind in _AMQP_INDICATORS
) or any(kw in exc_msg for kw in _AMQP_INDICATORS):
for ind in amqp_indicators
) or any(kw in exc_msg for kw in ["amqp", "pika", "rabbitmq"]):
return None
# User-caused credential/auth/integration errors — not platform bugs
if any(kw in exc_msg for kw in _USER_AUTH_KEYWORDS):
# User-caused credential/auth errors — not platform bugs
user_auth_keywords = [
"incorrect api key",
"invalid x-api-key",
"missing authentication header",
"invalid api token",
"authentication_error",
]
if any(kw in exc_msg for kw in user_auth_keywords):
return None
# Expected business logic — insufficient balance
@@ -103,18 +93,18 @@ def _before_send(event, hint):
)
if event.get("logger") and log_msg:
msg = log_msg.lower()
noisy_log_patterns = [
noisy_patterns = [
"amqpconnection",
"connection_forced",
"unclosed client session",
"unclosed connector",
]
if any(p in msg for p in noisy_log_patterns):
if any(p in msg for p in noisy_patterns):
return None
if "connection refused" in msg and any(ind in msg for ind in _AMQP_INDICATORS):
return None
# Same auth keywords — errors logged via logger.error() bypass exc_info
if any(kw in msg for kw in _USER_AUTH_KEYWORDS):
# "connection refused" in logs only when AMQP-related context is present
if "connection refused" in msg and any(
ind in msg for ind in ("amqp", "pika", "rabbitmq", "aio_pika", "aiormq")
):
return None
return event

View File

@@ -1,9 +1,17 @@
import base64
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from backend.blocks.google.gmail import GmailReadBlock
from backend.blocks.google.gmail import (
GmailForwardBlock,
GmailReadBlock,
_build_reply_message,
create_mime_message,
validate_email_recipients,
)
from backend.data.execution import ExecutionContext
class TestGmailReadBlock:
@@ -250,3 +258,258 @@ class TestGmailReadBlock:
result = await self.gmail_block._get_email_body(msg, self.mock_service)
assert result == "This email does not contain a readable body."
class TestValidateEmailRecipients:
"""Test cases for validate_email_recipients."""
def test_valid_single_email(self):
validate_email_recipients(["user@example.com"])
def test_valid_multiple_emails(self):
validate_email_recipients(["a@b.com", "x@y.org", "test@sub.domain.co"])
def test_invalid_missing_at(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients(["not-an-email"])
def test_invalid_missing_domain_dot(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients(["user@localhost"])
def test_invalid_empty_string(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients([""])
def test_invalid_json_object_string(self):
with pytest.raises(ValueError, match="Invalid email address"):
validate_email_recipients(['{"email": "user@example.com"}'])
def test_mixed_valid_and_invalid(self):
with pytest.raises(ValueError, match="'bad-addr'"):
validate_email_recipients(["good@example.com", "bad-addr"])
def test_field_name_in_error(self):
with pytest.raises(ValueError, match="'cc'"):
validate_email_recipients(["nope"], field_name="cc")
def test_whitespace_trimmed(self):
validate_email_recipients([" user@example.com "])
def test_empty_list_passes(self):
validate_email_recipients([])
class TestCreateMimeMessageValidation:
"""Test that create_mime_message() raises ValueError for invalid recipients."""
@staticmethod
def _make_input(to=None, cc=None, bcc=None):
return SimpleNamespace(
to=to or ["valid@example.com"],
cc=cc or [],
bcc=bcc or [],
subject="Test",
body="Hello",
content_type=None,
attachments=[],
)
@staticmethod
def _exec_ctx():
return ExecutionContext(user_id="u1", graph_exec_id="g1")
@pytest.mark.asyncio
async def test_invalid_to_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await create_mime_message(
self._make_input(to=["not-an-email"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_cc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'cc'"):
await create_mime_message(
self._make_input(cc=["bad-addr"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_bcc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'bcc'"):
await create_mime_message(
self._make_input(bcc=["nope"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_valid_recipients_no_error(self):
"""Sanity check: valid emails should not raise."""
result = await create_mime_message(
self._make_input(
to=["alice@example.com"],
cc=["bob@example.com"],
bcc=["carol@example.com"],
),
self._exec_ctx(),
)
assert isinstance(result, str) # base64-encoded message
class TestBuildReplyMessageValidation:
"""Test that _build_reply_message() raises ValueError for invalid recipients."""
@staticmethod
def _make_input(to=None, cc=None, bcc=None):
return SimpleNamespace(
threadId="t1",
parentMessageId="m1",
to=to or [],
cc=cc or [],
bcc=bcc or [],
replyAll=False,
subject="",
body="Reply body",
content_type=None,
attachments=[],
)
@staticmethod
def _exec_ctx():
return ExecutionContext(user_id="u1", graph_exec_id="g1")
@staticmethod
def _mock_service(from_addr="sender@example.com"):
"""Build a mock Gmail service that returns a parent message."""
parent_message = {
"payload": {
"headers": [
{"name": "Subject", "value": "Original subject"},
{"name": "Message-ID", "value": "<abc@mail.example.com>"},
{"name": "From", "value": from_addr},
{"name": "To", "value": "me@example.com"},
]
}
}
svc = Mock()
svc.users().messages().get().execute.return_value = parent_message
return svc
@pytest.mark.asyncio
async def test_invalid_to_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await _build_reply_message(
self._mock_service(),
self._make_input(to=["bad-addr"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_cc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'cc'"):
await _build_reply_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], cc=["not-valid"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_bcc_raises(self):
with pytest.raises(ValueError, match="Invalid email address.*'bcc'"):
await _build_reply_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], bcc=["nope"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_auto_resolved_invalid_from_raises(self):
"""When to/cc/bcc are empty, recipients are resolved from the parent.
If the parent From header is invalid, validation should still fire."""
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await _build_reply_message(
self._mock_service(from_addr="bad-sender"),
self._make_input(), # to=[] triggers auto-resolution
self._exec_ctx(),
)
class TestForwardMessageValidation:
"""Test that _forward_message() raises ValueError for invalid recipients."""
@staticmethod
def _make_input(
to: list[str] | None = None,
cc: list[str] | None = None,
bcc: list[str] | None = None,
) -> "GmailForwardBlock.Input":
mock = Mock(spec=GmailForwardBlock.Input)
mock.messageId = "m1"
mock.to = to or []
mock.cc = cc or []
mock.bcc = bcc or []
mock.subject = ""
mock.forwardMessage = "FYI"
mock.includeAttachments = False
mock.content_type = None
mock.additionalAttachments = []
mock.credentials = None
return mock
@staticmethod
def _exec_ctx():
return ExecutionContext(user_id="u1", graph_exec_id="g1")
@staticmethod
def _mock_service():
"""Build a mock Gmail service that returns a parent message."""
parent_message = {
"id": "m1",
"payload": {
"headers": [
{"name": "Subject", "value": "Original subject"},
{"name": "From", "value": "sender@example.com"},
{"name": "To", "value": "me@example.com"},
{"name": "Date", "value": "Mon, 31 Mar 2026 00:00:00 +0000"},
],
"mimeType": "text/plain",
"body": {
"data": base64.urlsafe_b64encode(b"Hello world").decode(),
},
"parts": [],
},
}
svc = Mock()
svc.users().messages().get().execute.return_value = parent_message
return svc
@pytest.mark.asyncio
async def test_invalid_to_raises(self):
block = GmailForwardBlock()
with pytest.raises(ValueError, match="Invalid email address.*'to'"):
await block._forward_message(
self._mock_service(),
self._make_input(to=["bad-addr"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_cc_raises(self):
block = GmailForwardBlock()
with pytest.raises(ValueError, match="Invalid email address.*'cc'"):
await block._forward_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], cc=["not-valid"]),
self._exec_ctx(),
)
@pytest.mark.asyncio
async def test_invalid_bcc_raises(self):
block = GmailForwardBlock()
with pytest.raises(ValueError, match="Invalid email address.*'bcc'"):
await block._forward_message(
self._mock_service(),
self._make_input(to=["valid@example.com"], bcc=["nope"]),
self._exec_ctx(),
)

View File

@@ -1,5 +1,5 @@
# Base stage for both dev and prod
FROM node:22.22-alpine3.23 AS base
FROM node:21-alpine AS base
WORKDIR /app
RUN corepack enable
COPY autogpt_platform/frontend/package.json autogpt_platform/frontend/pnpm-lock.yaml ./
@@ -33,7 +33,7 @@ ENV NEXT_PUBLIC_SOURCEMAPS="false"
RUN if [ "$NEXT_PUBLIC_PW_TEST" = "true" ]; then NEXT_PUBLIC_PW_TEST=true NODE_OPTIONS="--max-old-space-size=8192" pnpm build; else NODE_OPTIONS="--max-old-space-size=8192" pnpm build; fi
# Prod stage - based on NextJS reference Dockerfile https://github.com/vercel/next.js/blob/64271354533ed16da51be5dce85f0dbd15f17517/examples/with-docker/Dockerfile
FROM node:22.22-alpine3.23 AS prod
FROM node:21-alpine AS prod
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
WORKDIR /app

View File

@@ -5,8 +5,6 @@ import {
getV2GetAdminListingsHistory,
postV2ReviewStoreSubmission,
getV2AdminDownloadAgentFile,
getV2AdminPreviewSubmissionListing,
postV2AdminAddPendingAgentToLibrary,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { okData } from "@/app/api/helpers";
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
@@ -60,17 +58,3 @@ export async function downloadAsAdmin(storeListingVersion: string) {
const response = await getV2AdminDownloadAgentFile(storeListingVersion);
return okData(response);
}
export async function previewAsAdmin(storeListingVersionId: string) {
const response = await getV2AdminPreviewSubmissionListing(
storeListingVersionId,
);
return okData(response);
}
export async function addToLibraryAsAdmin(storeListingVersionId: string) {
const response = await postV2AdminAddPendingAgentToLibrary(
storeListingVersionId,
);
return okData(response);
}

View File

@@ -10,13 +10,11 @@ import {
TableBody,
} from "@/components/__legacy__/ui/table";
import { Badge } from "@/components/__legacy__/ui/badge";
import { ChevronDown, ChevronRight, Eye } from "lucide-react";
import Link from "next/link";
import { ChevronDown, ChevronRight } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import type { StoreListingWithVersionsAdminView } from "@/app/api/__generated__/models/storeListingWithVersionsAdminView";
import type { StoreSubmissionAdminView } from "@/app/api/__generated__/models/storeSubmissionAdminView";
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
import { Button } from "@/components/__legacy__/ui/button";
import { ApproveRejectButtons } from "./ApproveRejectButton";
import { DownloadAgentAdminButton } from "./DownloadAgentButton";
@@ -78,19 +76,9 @@ export function ExpandableRow({
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{latestVersion?.listing_version_id && (
<>
<Link
href={`/admin/marketplace/preview/${latestVersion.listing_version_id}`}
>
<Button size="sm" variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</Link>
<DownloadAgentAdminButton
storeListingVersionId={latestVersion.listing_version_id}
/>
</>
<DownloadAgentAdminButton
storeListingVersionId={latestVersion.listing_version_id}
/>
)}
{(latestVersion?.status === SubmissionStatus.PENDING ||
@@ -120,7 +108,7 @@ export function ExpandableRow({
<TableHead>Name</TableHead>
<TableHead>Sub Heading</TableHead>
<TableHead>Description</TableHead>
<TableHead>Categories</TableHead>
{/* <TableHead>Categories</TableHead> */}
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -192,29 +180,15 @@ export function ExpandableRow({
<TableCell>{version.name}</TableCell>
<TableCell>{version.sub_heading}</TableCell>
<TableCell>{version.description}</TableCell>
<TableCell>
{version.categories.length > 0
? version.categories.join(", ")
: "None"}
</TableCell>
{/* <TableCell>{version.categories.join(", ")}</TableCell> */}
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{version.listing_version_id && (
<>
<Link
href={`/admin/marketplace/preview/${version.listing_version_id}`}
>
<Button size="sm" variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</Link>
<DownloadAgentAdminButton
storeListingVersionId={
version.listing_version_id
}
/>
</>
<DownloadAgentAdminButton
storeListingVersionId={
version.listing_version_id
}
/>
)}
{(version.status === SubmissionStatus.PENDING ||
version.status === SubmissionStatus.APPROVED) && (

View File

@@ -1,172 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { ArrowLeft } from "@phosphor-icons/react";
import { AgentInfo } from "@/app/(platform)/marketplace/components/AgentInfo/AgentInfo";
import { AgentImages } from "@/app/(platform)/marketplace/components/AgentImages/AgentImage";
import type { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import { previewAsAdmin, addToLibraryAsAdmin } from "../../actions";
import { useToast } from "@/components/molecules/Toast/use-toast";
export default function AdminPreviewPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const { toast } = useToast();
const [data, setData] = useState<StoreAgentDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
useEffect(() => {
async function load() {
try {
const result = await previewAsAdmin(params.id);
setData(result as StoreAgentDetails);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load preview");
} finally {
setIsLoading(false);
}
}
load();
}, [params.id]);
async function handleAddToLibrary() {
setIsAddingToLibrary(true);
try {
await addToLibraryAsAdmin(params.id);
toast({
title: "Added to Library",
description: "Agent has been added to your library for review.",
duration: 3000,
});
} catch (e) {
toast({
title: "Error",
description:
e instanceof Error ? e.message : "Failed to add agent to library.",
variant: "destructive",
});
} finally {
setIsAddingToLibrary(false);
}
}
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Loading preview...</p>
</div>
);
}
if (error || !data) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<p className="text-destructive">{error || "Preview not found"}</p>
<button
onClick={() => router.back()}
className="text-muted-foreground underline"
>
Go back
</button>
</div>
);
}
const allMedia = [
...(data.agent_video ? [data.agent_video] : []),
...(data.agent_output_demo ? [data.agent_output_demo] : []),
...data.agent_image,
];
return (
<div className="container mx-auto max-w-7xl px-4 py-6">
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
Back to Admin Marketplace
</button>
<div className="flex items-center gap-3">
<span className="rounded-md bg-amber-500/20 px-3 py-1 text-sm font-medium text-amber-600">
Admin Preview
{!data.has_approved_version && " — Pending Approval"}
</span>
<button
onClick={handleAddToLibrary}
disabled={isAddingToLibrary}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isAddingToLibrary ? "Adding..." : "Add to My Library"}
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
<div className="lg:col-span-2">
<AgentInfo
user={null}
agentId={data.graph_id}
name={data.agent_name}
creator={data.creator}
creatorAvatar={data.creator_avatar}
shortDescription={data.sub_heading}
longDescription={data.description}
runs={data.runs}
categories={data.categories}
lastUpdated={String(data.last_updated)}
version={data.versions[0] || "1"}
storeListingVersionId={data.store_listing_version_id}
isAgentAddedToLibrary={false}
/>
</div>
<div className="lg:col-span-3">
{allMedia.length > 0 ? (
<AgentImages images={allMedia} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
<p className="text-muted-foreground">
No images or videos submitted
</p>
</div>
)}
</div>
</div>
{/* Fields not shown in AgentInfo but important for admin review */}
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
{data.instructions && (
<div className="rounded-lg border p-4">
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
Instructions
</h3>
<p className="whitespace-pre-wrap text-sm">{data.instructions}</p>
</div>
)}
{data.recommended_schedule_cron && (
<div className="rounded-lg border p-4">
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
Recommended Schedule
</h3>
<code className="rounded bg-muted px-2 py-1 text-sm">
{data.recommended_schedule_cron}
</code>
</div>
)}
<div className="rounded-lg border p-4">
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
Slug
</h3>
<code className="rounded bg-muted px-2 py-1 text-sm">
{data.slug}
</code>
</div>
</div>
</div>
);
}

View File

@@ -290,12 +290,12 @@ export function ChatSidebar() {
<div className="flex min-h-[30rem] items-center justify-center py-4">
<LoadingSpinner size="small" className="text-neutral-600" />
</div>
) : !sessions?.length ? (
) : sessions.length === 0 ? (
<p className="py-4 text-center text-sm text-neutral-500">
No conversations yet
</p>
) : (
sessions?.map((session) => (
sessions.map((session) => (
<div
key={session.id}
className={cn(

View File

@@ -20,7 +20,7 @@ export function UsageLimits() {
},
});
if (isLoading || !usage?.daily || !usage?.weekly) return null;
if (isLoading || !usage) return null;
if (usage.daily.limit <= 0 && usage.weekly.limit <= 0) return null;
return (

View File

@@ -34,7 +34,7 @@ function CoPilotUsageSection() {
},
});
if (isLoading || !usage?.daily || !usage?.weekly) return null;
if (isLoading || !usage) return null;
if (usage.daily.limit <= 0 && usage.weekly.limit <= 0) return null;
return (

View File

@@ -5680,82 +5680,6 @@
}
}
},
"/api/store/admin/submissions/{store_listing_version_id}/add-to-library": {
"post": {
"tags": ["v2", "admin", "store", "admin"],
"summary": "Admin Add Pending Agent to Library",
"description": "Add a pending marketplace agent to the admin's library for review.\nUses admin-level access to bypass marketplace APPROVED-only checks.\n\nThe builder can load the graph because get_graph() checks library\nmembership as a fallback: \"you added it, you keep it.\"",
"operationId": "postV2Admin add pending agent to library",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "store_listing_version_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Store Listing Version Id" }
}
],
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryAgent" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/admin/submissions/{store_listing_version_id}/preview": {
"get": {
"tags": ["v2", "admin", "store", "admin"],
"summary": "Admin Preview Submission Listing",
"description": "Preview a marketplace submission as it would appear on the listing page.\nBypasses the APPROVED-only StoreAgent view so admins can preview pending\nsubmissions before approving.",
"operationId": "getV2Admin preview submission listing",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "store_listing_version_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Store Listing Version Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StoreAgentDetails" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/admin/submissions/{store_listing_version_id}/review": {
"post": {
"tags": ["v2", "admin", "store", "admin"],