diff --git a/.gitignore b/.gitignore index 7275b95d63..0a3c51b685 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,6 @@ hosting/kubernetes/**/values.*.yaml # IDE/LSP config (local tooling) pyrightconfig.json .gstack/ + +# Agent skills lockfile (npx skills add) +skills-lock.json diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index de21f95e6c..1443846587 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -4,7 +4,7 @@ import re from datetime import datetime, timezone from functools import wraps -from typing import List, Optional +from typing import Any, Dict, List, Optional from urllib.parse import urlsplit from uuid import UUID, uuid4 @@ -894,6 +894,23 @@ async def callback_connection( state: Optional[str] = Query(default=None), ) -> HTMLResponse: """Handle OAuth callback from Composio.""" + # Decode the HMAC-signed state up front to recover BOTH the project scope and the + # connection identity. The identity tags every card (success or failure) so the + # opener can tell WHICH connect flow finished — the playground can have several + # live at once (see ConnectToolWidget), and an untagged completion would settle + # all of them. + state_payload = ( + decode_oauth_state(state, secret_key=env.agenta.crypt_key) + if state + else None + ) + if not state: + log.warning("OAuth callback received without state token") + elif state_payload is None: + log.warning("OAuth callback: invalid or expired state token") + state_slug = state_payload.get("slug") if state_payload else None + state_integration = state_payload.get("integration") if state_payload else None + if error_message or status == "failed": log.error("OAuth callback failed: status=%s", status) return HTMLResponse( @@ -901,6 +918,8 @@ async def callback_connection( content=_oauth_card( success=False, error=error_message or "Authorization failed. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) @@ -910,24 +929,19 @@ async def callback_connection( content=_oauth_card( success=False, error="Missing connection identifier. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) - # Decode HMAC-signed state to recover project scope. Activation is - # project-scoped, so a missing/invalid state is fatal — we never activate - # without a resolved project_id. + # Activation is project-scoped, so a missing/invalid state is fatal — we never + # activate without a resolved project_id. project_id: Optional[UUID] = None - if state: - payload = decode_oauth_state(state, secret_key=env.agenta.crypt_key) - if payload is None: - log.warning("OAuth callback: invalid or expired state token") - else: - try: - project_id = UUID(payload["project_id"]) - except (KeyError, ValueError): - log.warning("OAuth callback state missing or invalid project_id") - else: - log.warning("OAuth callback received without state token") + if state_payload is not None: + try: + project_id = UUID(state_payload["project_id"]) + except (KeyError, ValueError): + log.warning("OAuth callback state missing or invalid project_id") if project_id is None: return HTMLResponse( @@ -935,6 +949,8 @@ async def callback_connection( content=_oauth_card( success=False, error="Connection could not be activated. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) @@ -954,6 +970,8 @@ async def callback_connection( content=_oauth_card( success=False, error="Connection could not be activated. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) except Exception: @@ -963,6 +981,8 @@ async def callback_connection( content=_oauth_card( success=False, error="An internal error occurred. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) @@ -992,6 +1012,8 @@ async def callback_connection( integration_logo=integration_logo, integration_url=integration_url, agenta_url=env.agenta.web_url, + slug=conn.slug, + integration_key=conn.integration_key, ), ) @@ -1428,6 +1450,13 @@ async def _emit_data_event( # --------------------------------------------------------------------------- +def _json_for_inline_script(value: Any) -> str: + # `json.dumps` leaves `<` intact, so a value containing `` would terminate + # the inline