Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ DEFAULT_TEAM_CHAT=wegent-chat#default
DEFAULT_TEAM_KNOWLEDGE=wegent-notebook#default
DEFAULT_TEAM_TASK=wegent-wework#default

# Cloud project ID that receives Wework feedback. Empty disables submission.
WEWORK_FEEDBACK_PROJECT_ID=
# Maximum uploaded diagnostic bundle size in MB.
WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB=250

# Long-term memory configuration (mem0)
# Optional API key for mem0 service authentication
MEMORY_API_KEY=
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
device_chat_tasks,
devices,
dingtalk_docs,
feedback,
groups,
health,
im_sessions,
Expand Down Expand Up @@ -136,6 +137,7 @@
cloud_projects.router, prefix="/v1/cloud-projects", tags=["cloud-projects"]
)
api_router.include_router(deliveries.router, prefix="/v1", tags=["deliveries"])
api_router.include_router(feedback.router, prefix="/v1/feedback", tags=["feedback"])
api_router.include_router(api_keys.router, prefix="/api-keys", tags=["api-keys"])
api_router.include_router(devices.router, prefix="/devices", tags=["devices"])
api_router.include_router(
Expand Down
50 changes: 35 additions & 15 deletions backend/app/api/endpoints/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
UploadFile,
status,
)
from fastapi.responses import Response
from sqlalchemy.orm import Session

from app.api.dependencies import get_db
from app.core.config import settings
from app.core.security import get_current_user
from app.models.delivery import Delivery
from app.models.user import User
Expand Down Expand Up @@ -48,6 +50,10 @@
from app.services.delivery import delivery_service
from app.services.loop_items import loop_item_service
from app.services.loop_items.external_provider import external_loop_item_provider
from app.services.loop_items.provider_router import (
loop_item_attachment_provider_router,
loop_item_provider_router,
)

router = APIRouter()

Expand Down Expand Up @@ -242,14 +248,8 @@ def create_loop_item(
current_user: User = Depends(get_current_user),
) -> LoopItemResponse:
project = cloud_project_service.get(db, project_id, current_user.id)
if project.task_provider in {"github", "gitlab"}:
return LoopItemResponse.model_validate(
external_loop_item_provider.create(
db, project_id, current_user.id, current_user.user_name, values
)
)
item = loop_item_service.create(db, project_id, current_user.id, values)
return _loop_item_response(db, item, current_user)
created = loop_item_provider_router.create(db, project, current_user, values)
return LoopItemResponse.model_validate(created.values)


@router.post(
Expand Down Expand Up @@ -334,8 +334,9 @@ def list_loop_item_attachments(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LoopItemAttachmentResponse]:
external_loop_item_provider.ensure_shadow(db, item_id, current_user.id)
attachments = loop_item_service.list_attachments(db, item_id, current_user.id)
attachments = loop_item_attachment_provider_router.list(
db, item_id, current_user.id
)
return [LoopItemAttachmentResponse.model_validate(item) for item in attachments]


Expand All @@ -350,14 +351,14 @@ def add_loop_item_attachment(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LoopItemAttachmentResponse:
external_loop_item_provider.ensure_shadow(db, item_id, current_user.id)
attachment = loop_item_service.add_attachment(
attachment = loop_item_attachment_provider_router.add(
db,
item_id,
current_user.id,
file.filename or "attachment",
file.content_type or "application/octet-stream",
file.file,
settings.DELIVERY_MAX_ASSET_SIZE_MB * 1024 * 1024,
)
return LoopItemAttachmentResponse.model_validate(attachment)

Expand All @@ -371,9 +372,28 @@ def access_loop_item_attachment(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LoopItemAttachmentAccessResponse:
loop_item_attachment_provider_router.require_access(
db, attachment_id, current_user.id
)
return LoopItemAttachmentAccessResponse(
url=loop_item_service.attachment_access_url(db, attachment_id, current_user.id),
expires_in_seconds=900,
url=f"wegent://attachments/{attachment_id}",
expires_in_seconds=0,
)


@router.get("/loop-item-attachments/{attachment_id}/content")
def read_loop_item_attachment(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Response:
content, content_type, filename = loop_item_attachment_provider_router.content(
db, attachment_id, current_user.id
)
return Response(
content=content,
media_type=content_type,
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
Comment on lines +384 to 397

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Serving attachment bytes inline with a provider-supplied media_type and unescaped filename is risky.

Two concerns on this new endpoint:

  1. content_type originates from the uploaded file (file.content_type at Line 359 for local attachments). Serving text/html or image/svg+xml inline from the API origin allows stored XSS against any session-authenticated browser context.
  2. filename is unsanitized user input; a " or CR/LF in the display name corrupts or injects response headers.

Prefer Content-Disposition: attachment with RFC 5987 encoding, plus X-Content-Type-Options: nosniff.

🔒 Proposed fix
+from urllib.parse import quote as _url_quote
     content, content_type, filename = loop_item_attachment_provider_router.content(
         db, attachment_id, current_user.id
     )
+    safe_name = _url_quote(filename or "attachment")
     return Response(
         content=content,
         media_type=content_type,
-        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+        headers={
+            "Content-Disposition": f"attachment; filename*=UTF-8''{safe_name}",
+            "X-Content-Type-Options": "nosniff",
+        },
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@router.get("/loop-item-attachments/{attachment_id}/content")
def read_loop_item_attachment(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Response:
content, content_type, filename = loop_item_attachment_provider_router.content(
db, attachment_id, current_user.id
)
return Response(
content=content,
media_type=content_type,
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
from urllib.parse import quote as _url_quote
`@router.get`("/loop-item-attachments/{attachment_id}/content")
def read_loop_item_attachment(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Response:
content, content_type, filename = loop_item_attachment_provider_router.content(
db, attachment_id, current_user.id
)
safe_name = _url_quote(filename or "attachment")
return Response(
content=content,
media_type=content_type,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{safe_name}",
"X-Content-Type-Options": "nosniff",
},
)
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 387-387: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 388-388: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/endpoints/deliveries.py` around lines 384 - 397, Update
read_loop_item_attachment to serve the file with Content-Disposition attachment
instead of inline, encode the provider-supplied filename using RFC 5987 rather
than interpolating it directly, and add X-Content-Type-Options: nosniff to the
response headers while preserving the returned content and media type.



Expand All @@ -385,7 +405,7 @@ def delete_loop_item_attachment(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
loop_item_service.delete_attachment(db, attachment_id, current_user.id)
loop_item_attachment_provider_router.delete(db, attachment_id, current_user.id)


@router.get(
Expand Down
45 changes: 45 additions & 0 deletions backend/app/api/endpoints/feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
# SPDX-License-Identifier: Apache-2.0

"""Authenticated Wework feedback endpoint."""

import json

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import ValidationError
from sqlalchemy.orm import Session

from app.api.dependencies import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
from app.services.feedback_service import feedback_service

router = APIRouter()


@router.post("", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED)
def submit_feedback(
report_id: str = Form(...),
title: str = Form(...),
description: str = Form(""),
context: str = Form("{}"),
bundle: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> FeedbackResponse:
try:
parsed_context = json.loads(context)
values = FeedbackCreate(
report_id=report_id,
title=title,
description=description,
context=parsed_context,
)
except (json.JSONDecodeError, ValidationError) as error:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(error)) from error
if not isinstance(parsed_context, dict):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "context must be an object"
)
return feedback_service.submit(db, current_user, values, bundle)
4 changes: 4 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,10 @@ def parse_rag_runtime_mode(cls, v: Any) -> str | dict[str, str]:
DEFAULT_TEAM_WEWORK: str = (
"wegent-wework#default" # Default team for WeWork workbench
)
# Cloud project that receives authenticated Wework feedback submissions.
# An empty value disables the feedback channel.
WEWORK_FEEDBACK_PROJECT_ID: str = ""
WEWORK_FEEDBACK_MAX_BUNDLE_SIZE_MB: int = 250

# JSON configuration for MCP servers (similar to Claude Desktop format)
# Example:
Expand Down
2 changes: 0 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ def _format_forwarded_headers_for_log(headers) -> str:

def _get_mcp_lifespan_servers():
from app.mcp_server.server import (
delivery_mcp_server,
interactive_form_question_mcp_server,
knowledge_mcp_server,
prompt_optimization_mcp_server,
Expand All @@ -105,7 +104,6 @@ def _get_mcp_lifespan_servers():
("interactive_form_question", interactive_form_question_mcp_server),
("Prompt optimization", prompt_optimization_mcp_server),
("Subscription", subscription_mcp_server),
("Delivery", delivery_mcp_server),
]
if settings.EXTERNAL_KNOWLEDGE_MCP_ENABLED:
from app.mcp_server.server import external_knowledge_mcp_server
Expand Down
112 changes: 0 additions & 112 deletions backend/app/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,6 @@
PROMPT_OPTIMIZATION_MCP_TRANSPORT_PATH = "/sse"
SUBSCRIPTION_MCP_MOUNT_PATH = "/mcp/subscription"
SUBSCRIPTION_MCP_TRANSPORT_PATH = "/sse"
DELIVERY_MCP_MOUNT_PATH = "/mcp/delivery"
DELIVERY_MCP_TRANSPORT_PATH = "/sse"
PROJECT_SPACE_PROTOCOL = "wegent.project-space"
PROJECT_SPACE_PROTOCOL_VERSION = 1
PROJECT_SPACE_CAPABILITIES = {
"projects.read": True,
"projects.create": True,
"todos.read": True,
"todos.write": True,
"files.read": True,
"deliveries.read": True,
}


@dataclass(frozen=True)
Expand Down Expand Up @@ -538,82 +526,6 @@ def ensure_subscription_tools_registered() -> None:
_register_subscription_tools()


# ============== Delivery MCP Server ==============

delivery_mcp_server = FastMCP(
"wegent_delivery",
stateless_http=True,
json_response=True,
streamable_http_path="/",
transport_security=_build_transport_security_settings(),
)
_delivery_request_token_info: contextvars.ContextVar[Optional[TaskTokenInfo]] = (
contextvars.ContextVar("_delivery_request_token_info", default=None)
)
_delivery_tools_registered = False


def ensure_delivery_tools_registered() -> None:
"""Register the project-space tools and addressable cloud resources."""
global _delivery_tools_registered
if _delivery_tools_registered:
return
from app.mcp_server.tool_registry import register_tools_to_server
from app.mcp_server.tools import delivery # noqa: F401

count = register_tools_to_server(delivery_mcp_server, "delivery")
delivery_mcp_server.resource(
"cloud://projects",
name="Wegent project spaces",
description="Every project space accessible to the authenticated user.",
mime_type="application/json",
)(_read_cloud_projects_resource)
delivery_mcp_server.resource(
"cloud://projects/{project_id}",
name="Wegent project space",
description="A project space with its shared workspace and board items.",
mime_type="application/json",
)(_read_cloud_project_resource)
delivery_mcp_server.resource(
"cloud://projects/{project_id}/{resource_type}/{resource_id}",
name="Wegent project-space object",
description="A task, file, or delivery in a project space.",
mime_type="application/json",
)(_read_cloud_object_resource)
logger.info("[MCP:Delivery] Registered %s tools", count)
_delivery_tools_registered = True


def _delivery_resource_token() -> MCPAuthInfo:
token_info = get_token_info_from_context()
if token_info is None:
raise PermissionError("Authentication required")
return token_info


def _serialize_delivery_resource(reference: str) -> str:
from app.mcp_server.tools.delivery import resolve_cloud_reference

result = resolve_cloud_reference(reference, _delivery_resource_token())
return json.dumps(result, ensure_ascii=False, default=str)


def _read_cloud_projects_resource() -> str:
return _serialize_delivery_resource("cloud://projects")


def _read_cloud_project_resource(project_id: str) -> str:
return _serialize_delivery_resource(f"cloud://projects/{project_id}")


def _read_cloud_object_resource(
project_id: str, resource_type: str, resource_id: str
) -> str:
return _serialize_delivery_resource(
f"cloud://projects/{project_id}/{resource_type}/{resource_id}"
)


# ============== Starlette App Factory ==============

_SYSTEM_MCP_SPEC = McpAppSpec(
Expand Down Expand Up @@ -671,25 +583,12 @@ def _read_cloud_object_resource(
include_root_metadata=True,
)

_DELIVERY_MCP_SPEC = McpAppSpec(
name="delivery",
service_name="wegent_delivery",
mount_path=DELIVERY_MCP_MOUNT_PATH,
transport_path=DELIVERY_MCP_TRANSPORT_PATH,
server=delivery_mcp_server,
token_context=_delivery_request_token_info,
log_prefix="Delivery",
include_root_metadata=True,
allow_user_token=True,
)

MCP_APP_SPECS = (
_SYSTEM_MCP_SPEC,
_KNOWLEDGE_MCP_SPEC,
_INTERACTIVE_FORM_MCP_SPEC,
_PROMPT_OPTIMIZATION_MCP_SPEC,
_SUBSCRIPTION_MCP_SPEC,
_DELIVERY_MCP_SPEC,
)

MCP_CONTEXT_SERVER_NAMES = frozenset(
Expand All @@ -698,7 +597,6 @@ def _read_cloud_object_resource(
"interactive_form_question",
"prompt_optimization",
"subscription",
"delivery",
}
)

Expand All @@ -712,14 +610,6 @@ def _build_root_metadata(spec: McpAppSpec) -> Dict[str, Any]:
"health": f"{spec.mount_path}/health",
},
}
if spec.name == "delivery":
metadata.update(
{
"protocol": PROJECT_SPACE_PROTOCOL,
"protocolVersion": PROJECT_SPACE_PROTOCOL_VERSION,
"capabilities": PROJECT_SPACE_CAPABILITIES,
}
)
return metadata


Expand All @@ -742,8 +632,6 @@ def _build_mcp_app(spec: McpAppSpec) -> Starlette:
ensure_prompt_optimization_tools_registered()
elif spec.name == "subscription":
ensure_subscription_tools_registered()
elif spec.name == "delivery":
ensure_delivery_tools_registered()

@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
Expand Down
Loading
Loading