Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
57 changes: 48 additions & 9 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1251,23 +1251,46 @@ async def lifespan(app: FastAPI):
# and drop the wildcard regex. Auth is primarily header-based
# (X-Wayforth-API-Key), so cookie CSRF surface is limited, but tightening
# this is defense in depth.
# Single source of truth for the allow-listed browser origins — used by both
# CORSMiddleware (the normal path) and the 500 handler below (the error path that
# bypasses CORSMiddleware). Keep them identical so error responses carry the same
# CORS contract as success responses.
_CORS_ALLOWED_ORIGINS = [
"https://wayforth.io",
"https://www.wayforth.io",
"https://gateway.wayforth.io",
"https://mcp.wayforth.io",
"https://zeropointaccess.com",
"https://www.zeropointaccess.com",
"https://intent-exchange.lovable.app",
]
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://wayforth.io",
"https://www.wayforth.io",
"https://gateway.wayforth.io",
"https://mcp.wayforth.io",
"https://zeropointaccess.com",
"https://www.zeropointaccess.com",
"https://intent-exchange.lovable.app",
],
allow_origins=_CORS_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS", "DELETE", "PUT"],
allow_headers=["*"],
)


def _cors_error_headers(request: "Request") -> dict:
"""CORS headers for error responses that bypass CORSMiddleware.

Unhandled 500s are rendered by Starlette's ServerErrorMiddleware, which sits
OUTSIDE CORSMiddleware — so a raw 500 carries no Access-Control-* headers and
the browser surfaces a generic CORS/"Load failed" error that masks EVERY
gateway 5xx. Re-apply the same allow-credentials CORS contract here: echo the
request Origin iff it is allow-listed (never '*' with credentials)."""
origin = request.headers.get("origin", "")
if origin in _CORS_ALLOWED_ORIGINS:
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
return {}


# Request body size limits (defense-in-depth against memory exhaustion / JSON
# bombs). Most endpoints take small JSON payloads — capped at 1 MB. /run and
# /execute (including /execute/batch and /run/intents) carry larger payloads
Expand Down Expand Up @@ -1538,6 +1561,22 @@ async def _payment_required_handler(request: Request, exc: HTTPException):
app.add_exception_handler(HTTPException, _payment_required_handler)


# Unhandled-exception (500) handler. Starlette routes Exception/500 to
# ServerErrorMiddleware, which renders OUTSIDE CORSMiddleware — so without this,
# every 5xx reaches the browser with no Access-Control-* headers and looks like a
# CORS failure ("Load failed"), masking the real server error. We render a clean
# 500 and re-attach the CORS headers ourselves so the frontend can read it.
async def _internal_error_handler(request: Request, exc: Exception):
logger.exception("unhandled_error path=%s method=%s", request.url.path, request.method)
return JSONResponse(
status_code=500,
content={"detail": "internal_error"},
headers=_cors_error_headers(request),
)

app.add_exception_handler(Exception, _internal_error_handler)


# FINDING-010 cleanup (v0.8.5): the duplicate check_auth that lived here was
# dead code — every route uses core.auth.check_auth — and carried the old
# in-memory anonymous-counter bypass. Removed.
Expand Down
20 changes: 20 additions & 0 deletions apps/api/routers/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,21 @@ def _resolve_run_key(header_key: str, agent: dict) -> str:
return ""


def _valid_uuid(value: str) -> bool:
try:
uuid.UUID(str(value))
return True
except (ValueError, TypeError, AttributeError):
return False


async def _get_agent_or_404(db, user_id: str, agent_id: str) -> dict:
# A malformed id (e.g. the frontend sending "undefined") can never identify an
# agent. Guard it as a clean 404 — passing it to a `$1::uuid` query otherwise
# raises asyncpg InvalidTextRepresentationError, which surfaced as an
# unhandled 500 (and, lacking CORS headers, looked like a CORS failure).
if not _valid_uuid(agent_id):
raise HTTPException(status_code=404, detail={"error": "agent_not_found"})
row = await db.fetchrow(
"SELECT * FROM hosted_agents WHERE id = $1::uuid AND user_id = $2::uuid",
agent_id, user_id,
Expand Down Expand Up @@ -805,6 +819,8 @@ async def get_run(
require_tier(tier, "cloud_agents")

await _get_agent_or_404(db, user_id, agent_id)
if not _valid_uuid(run_id):
raise HTTPException(status_code=404, detail={"error": "run_not_found"})

row = await db.fetchrow("""
SELECT id, status, trigger, sandbox_id, started_at, completed_at, duration_ms,
Expand Down Expand Up @@ -875,6 +891,8 @@ async def get_run_logs(
require_tier(tier, "cloud_agents")

await _get_agent_or_404(db, user_id, agent_id)
if not _valid_uuid(run_id):
raise HTTPException(status_code=404, detail={"error": "run_not_found"})

row = await db.fetchrow(
"SELECT status, log_tail FROM agent_runs WHERE id = $1::uuid AND hosted_agent_id = $2::uuid",
Expand Down Expand Up @@ -961,6 +979,8 @@ async def cancel_run(
require_tier(tier, "cloud_agents")

await _get_agent_or_404(db, user_id, agent_id)
if not _valid_uuid(run_id):
raise HTTPException(status_code=404, detail={"error": "run_not_found"})

row = await db.fetchrow(
"SELECT status FROM agent_runs WHERE id = $1::uuid AND hosted_agent_id = $2::uuid",
Expand Down
Loading