From 2cb6fd581cd2d47ab1d2b910c8d8fc68907141de Mon Sep 17 00:00:00 2001 From: Swifty Date: Tue, 2 Dec 2025 17:42:53 +0100 Subject: [PATCH] feat(platform): Integration management from external api (#11472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow the external api to manage credentials ### Changes 🏗️ - add ability to external api to manage credentials ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] tested it works --- > [!NOTE] > Introduces external API endpoints to manage integrations (OAuth initiation/completion and credential CRUD), adds external OAuth state fields, and new API key permissions/config. > > - **External API – Integrations**: > - Add router `backend/server/external/routes/integrations.py` with endpoints to: > - `GET /v1/integrations/providers` list providers (incl. default scopes) > - `POST /v1/integrations/{provider}/oauth/initiate` and `POST /oauth/complete` for external OAuth (custom callback, state) > - `GET /v1/integrations/credentials` and `GET /{provider}/credentials` to list credentials > - `POST /{provider}/credentials` to create `api_key`, `user_password`, `host_scoped` creds; `DELETE /{provider}/credentials/{cred_id}` to delete > - Wire router in `backend/server/external/api.py`. > - **Auth/Permissions**: > - Add `APIKeyPermission` values: `MANAGE_INTEGRATIONS`, `READ_INTEGRATIONS`, `DELETE_INTEGRATIONS` (schema + migration + OpenAPI). > - **Data model / Store**: > - Extend `OAuthState` with external-flow fields: `callback_url`, `state_metadata`, `api_key_id`, `is_external`. > - Update `IntegrationCredentialsStore.store_state_token(...)` to accept/store external OAuth metadata. > - **OAuth providers**: > - Set GitHub handler `DEFAULT_SCOPES = ["repo"]` in `integrations/oauth/github.py`. > - **Config**: > - Add `config.external_oauth_callback_origins` in `backend/util/settings.py` to validate allowed OAuth callback origins. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 249bba9e5913d0bffe6cc9ff37a44bb3df537d72. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Nicholas Tindle Co-authored-by: Reinier van der Leer --- .../backend/backend/data/model.py | 12 + .../backend/integrations/credentials_store.py | 14 +- .../backend/backend/server/external/api.py | 2 + .../server/external/routes/integrations.py | 634 ++++++++++++++++++ .../backend/server/v2/chat/tools/run_agent.py | 54 +- .../backend/backend/util/settings.py | 6 + .../migration.sql | 4 + autogpt_platform/backend/schema.prisma | 3 + .../frontend/src/app/api/openapi.json | 5 +- 9 files changed, 713 insertions(+), 21 deletions(-) create mode 100644 autogpt_platform/backend/backend/server/external/routes/integrations.py create mode 100644 autogpt_platform/backend/migrations/20251127144817_add_api_key_integration_permissions/migration.sql diff --git a/autogpt_platform/backend/backend/data/model.py b/autogpt_platform/backend/backend/data/model.py index ca4d330301..25d91c5406 100644 --- a/autogpt_platform/backend/backend/data/model.py +++ b/autogpt_platform/backend/backend/data/model.py @@ -434,6 +434,18 @@ class OAuthState(BaseModel): code_verifier: Optional[str] = None """Unix timestamp (seconds) indicating when this OAuth state expires""" scopes: list[str] + # Fields for external API OAuth flows + callback_url: Optional[str] = None + """External app's callback URL for OAuth redirect""" + state_metadata: dict[str, Any] = Field(default_factory=dict) + """Metadata to echo back to external app on completion""" + initiated_by_api_key_id: Optional[str] = None + """ID of the API key that initiated this OAuth flow""" + + @property + def is_external(self) -> bool: + """Whether this OAuth flow was initiated via external API.""" + return self.callback_url is not None class UserMetadata(BaseModel): diff --git a/autogpt_platform/backend/backend/integrations/credentials_store.py b/autogpt_platform/backend/backend/integrations/credentials_store.py index b1d36c2010..7d805913b2 100644 --- a/autogpt_platform/backend/backend/integrations/credentials_store.py +++ b/autogpt_platform/backend/backend/integrations/credentials_store.py @@ -414,7 +414,15 @@ class IntegrationCredentialsStore: # ===================== OAUTH STATES ===================== # async def store_state_token( - self, user_id: str, provider: str, scopes: list[str], use_pkce: bool = False + self, + user_id: str, + provider: str, + scopes: list[str], + use_pkce: bool = False, + # New parameters for external API OAuth flows + callback_url: Optional[str] = None, + state_metadata: Optional[dict] = None, + initiated_by_api_key_id: Optional[str] = None, ) -> tuple[str, str]: token = secrets.token_urlsafe(32) expires_at = datetime.now(timezone.utc) + timedelta(minutes=10) @@ -427,6 +435,10 @@ class IntegrationCredentialsStore: code_verifier=code_verifier, expires_at=int(expires_at.timestamp()), scopes=scopes, + # External API OAuth flow fields + callback_url=callback_url, + state_metadata=state_metadata or {}, + initiated_by_api_key_id=initiated_by_api_key_id, ) async with self.edit_user_integrations(user_id) as user_integrations: diff --git a/autogpt_platform/backend/backend/server/external/api.py b/autogpt_platform/backend/backend/server/external/api.py index f865d69aef..3bafa63108 100644 --- a/autogpt_platform/backend/backend/server/external/api.py +++ b/autogpt_platform/backend/backend/server/external/api.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from backend.monitoring.instrumentation import instrument_fastapi from backend.server.middleware.security import SecurityHeadersMiddleware +from .routes.integrations import integrations_router from .routes.tools import tools_router from .routes.v1 import v1_router @@ -16,6 +17,7 @@ external_app = FastAPI( external_app.add_middleware(SecurityHeadersMiddleware) external_app.include_router(v1_router, prefix="/v1") external_app.include_router(tools_router, prefix="/v1") +external_app.include_router(integrations_router, prefix="/v1") # Add Prometheus instrumentation instrument_fastapi( diff --git a/autogpt_platform/backend/backend/server/external/routes/integrations.py b/autogpt_platform/backend/backend/server/external/routes/integrations.py new file mode 100644 index 0000000000..7454be1c2e --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/routes/integrations.py @@ -0,0 +1,634 @@ +""" +External API endpoints for integrations and credentials. + +This module provides endpoints for external applications (like Autopilot) to: +- Initiate OAuth flows with custom callback URLs +- Complete OAuth flows by exchanging authorization codes +- Create API key, user/password, and host-scoped credentials +- List and manage user credentials +""" + +import logging +from typing import TYPE_CHECKING, Annotated, Any, Literal, Optional, Union +from urllib.parse import urlparse + +from fastapi import APIRouter, Body, HTTPException, Path, Security, status +from prisma.enums import APIKeyPermission +from pydantic import BaseModel, Field, SecretStr + +from backend.data.api_key import APIKeyInfo +from backend.data.model import ( + APIKeyCredentials, + Credentials, + CredentialsType, + HostScopedCredentials, + OAuth2Credentials, + UserPasswordCredentials, +) +from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.integrations.oauth import CREDENTIALS_BY_PROVIDER, HANDLERS_BY_NAME +from backend.integrations.providers import ProviderName +from backend.server.external.middleware import require_permission +from backend.server.integrations.models import get_all_provider_names +from backend.util.settings import Settings + +if TYPE_CHECKING: + from backend.integrations.oauth import BaseOAuthHandler + +logger = logging.getLogger(__name__) +settings = Settings() +creds_manager = IntegrationCredentialsManager() + +integrations_router = APIRouter(prefix="/integrations", tags=["integrations"]) + + +# ==================== Request/Response Models ==================== # + + +class OAuthInitiateRequest(BaseModel): + """Request model for initiating an OAuth flow.""" + + callback_url: str = Field( + ..., description="The external app's callback URL for OAuth redirect" + ) + scopes: list[str] = Field( + default_factory=list, description="OAuth scopes to request" + ) + state_metadata: dict[str, Any] = Field( + default_factory=dict, + description="Arbitrary metadata to echo back on completion", + ) + + +class OAuthInitiateResponse(BaseModel): + """Response model for OAuth initiation.""" + + login_url: str = Field(..., description="URL to redirect user for OAuth consent") + state_token: str = Field(..., description="State token for CSRF protection") + expires_at: int = Field( + ..., description="Unix timestamp when the state token expires" + ) + + +class OAuthCompleteRequest(BaseModel): + """Request model for completing an OAuth flow.""" + + code: str = Field(..., description="Authorization code from OAuth provider") + state_token: str = Field(..., description="State token from initiate request") + + +class OAuthCompleteResponse(BaseModel): + """Response model for OAuth completion.""" + + credentials_id: str = Field(..., description="ID of the stored credentials") + provider: str = Field(..., description="Provider name") + type: str = Field(..., description="Credential type (oauth2)") + title: Optional[str] = Field(None, description="Credential title") + scopes: list[str] = Field(default_factory=list, description="Granted scopes") + username: Optional[str] = Field(None, description="Username from provider") + state_metadata: dict[str, Any] = Field( + default_factory=dict, description="Echoed metadata from initiate request" + ) + + +class CredentialSummary(BaseModel): + """Summary of a credential without sensitive data.""" + + id: str + provider: str + type: CredentialsType + title: Optional[str] = None + scopes: Optional[list[str]] = None + username: Optional[str] = None + host: Optional[str] = None + + +class ProviderInfo(BaseModel): + """Information about an integration provider.""" + + name: str + supports_oauth: bool = False + supports_api_key: bool = False + supports_user_password: bool = False + supports_host_scoped: bool = False + default_scopes: list[str] = Field(default_factory=list) + + +# ==================== Credential Creation Models ==================== # + + +class CreateAPIKeyCredentialRequest(BaseModel): + """Request model for creating API key credentials.""" + + type: Literal["api_key"] = "api_key" + api_key: str = Field(..., description="The API key") + title: str = Field(..., description="A name for this credential") + expires_at: Optional[int] = Field( + None, description="Unix timestamp when the API key expires" + ) + + +class CreateUserPasswordCredentialRequest(BaseModel): + """Request model for creating username/password credentials.""" + + type: Literal["user_password"] = "user_password" + username: str = Field(..., description="Username") + password: str = Field(..., description="Password") + title: str = Field(..., description="A name for this credential") + + +class CreateHostScopedCredentialRequest(BaseModel): + """Request model for creating host-scoped credentials.""" + + type: Literal["host_scoped"] = "host_scoped" + host: str = Field(..., description="Host/domain pattern to match") + headers: dict[str, str] = Field(..., description="Headers to include in requests") + title: str = Field(..., description="A name for this credential") + + +# Union type for credential creation +CreateCredentialRequest = Annotated[ + CreateAPIKeyCredentialRequest + | CreateUserPasswordCredentialRequest + | CreateHostScopedCredentialRequest, + Field(discriminator="type"), +] + + +class CreateCredentialResponse(BaseModel): + """Response model for credential creation.""" + + id: str + provider: str + type: CredentialsType + title: Optional[str] = None + + +# ==================== Helper Functions ==================== # + + +def validate_callback_url(callback_url: str) -> bool: + """Validate that the callback URL is from an allowed origin.""" + allowed_origins = settings.config.external_oauth_callback_origins + + try: + parsed = urlparse(callback_url) + callback_origin = f"{parsed.scheme}://{parsed.netloc}" + + for allowed in allowed_origins: + # Simple origin matching + if callback_origin == allowed: + return True + + # Allow localhost with any port in development (proper hostname check) + if parsed.hostname == "localhost": + for allowed in allowed_origins: + allowed_parsed = urlparse(allowed) + if allowed_parsed.hostname == "localhost": + return True + + return False + except Exception: + return False + + +def _get_oauth_handler_for_external( + provider_name: str, redirect_uri: str +) -> "BaseOAuthHandler": + """Get an OAuth handler configured with an external redirect URI.""" + # Ensure blocks are loaded so SDK providers are available + try: + from backend.blocks import load_all_blocks + + load_all_blocks() + except Exception as e: + logger.warning(f"Failed to load blocks: {e}") + + if provider_name not in HANDLERS_BY_NAME: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider_name}' does not support OAuth", + ) + + # Check if this provider has custom OAuth credentials + oauth_credentials = CREDENTIALS_BY_PROVIDER.get(provider_name) + + if oauth_credentials and not oauth_credentials.use_secrets: + import os + + client_id = ( + os.getenv(oauth_credentials.client_id_env_var) + if oauth_credentials.client_id_env_var + else None + ) + client_secret = ( + os.getenv(oauth_credentials.client_secret_env_var) + if oauth_credentials.client_secret_env_var + else None + ) + else: + client_id = getattr(settings.secrets, f"{provider_name}_client_id", None) + client_secret = getattr( + settings.secrets, f"{provider_name}_client_secret", None + ) + + if not (client_id and client_secret): + logger.error(f"Attempt to use unconfigured {provider_name} OAuth integration") + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail={ + "message": f"Integration with provider '{provider_name}' is not configured.", + "hint": "Set client ID and secret in the application's deployment environment", + }, + ) + + handler_class = HANDLERS_BY_NAME[provider_name] + return handler_class( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + + +# ==================== Endpoints ==================== # + + +@integrations_router.get("/providers", response_model=list[ProviderInfo]) +async def list_providers( + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.READ_INTEGRATIONS) + ), +) -> list[ProviderInfo]: + """ + List all available integration providers. + + Returns a list of all providers with their supported credential types. + Most providers support API key credentials, and some also support OAuth. + """ + # Ensure blocks are loaded + try: + from backend.blocks import load_all_blocks + + load_all_blocks() + except Exception as e: + logger.warning(f"Failed to load blocks: {e}") + + providers = [] + for name in get_all_provider_names(): + supports_oauth = name in HANDLERS_BY_NAME + handler_class = HANDLERS_BY_NAME.get(name) + default_scopes = ( + getattr(handler_class, "DEFAULT_SCOPES", []) if handler_class else [] + ) + + providers.append( + ProviderInfo( + name=name, + supports_oauth=supports_oauth, + supports_api_key=True, # All providers can accept API keys + supports_user_password=name in ("smtp",), # SMTP uses user/password + supports_host_scoped=name == "http", # HTTP block uses host-scoped + default_scopes=default_scopes, + ) + ) + + return providers + + +@integrations_router.post( + "/{provider}/oauth/initiate", + response_model=OAuthInitiateResponse, + summary="Initiate OAuth flow", +) +async def initiate_oauth( + provider: Annotated[str, Path(title="The OAuth provider")], + request: OAuthInitiateRequest, + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.MANAGE_INTEGRATIONS) + ), +) -> OAuthInitiateResponse: + """ + Initiate an OAuth flow for an external application. + + This endpoint allows external apps to start an OAuth flow with a custom + callback URL. The callback URL must be from an allowed origin configured + in the platform settings. + + Returns a login URL to redirect the user to, along with a state token + for CSRF protection. + """ + # Validate callback URL + if not validate_callback_url(request.callback_url): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Callback URL origin is not allowed. Allowed origins: {settings.config.external_oauth_callback_origins}", + ) + + # Validate provider + try: + provider_name = ProviderName(provider) + except ValueError: + # Check if it's a dynamically registered provider + if provider not in HANDLERS_BY_NAME: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider}' not found", + ) + provider_name = provider + + # Get OAuth handler with external callback URL + handler = _get_oauth_handler_for_external( + provider if isinstance(provider_name, str) else provider_name.value, + request.callback_url, + ) + + # Store state token with external flow metadata + state_token, code_challenge = await creds_manager.store.store_state_token( + user_id=api_key.user_id, + provider=provider if isinstance(provider_name, str) else provider_name.value, + scopes=request.scopes, + callback_url=request.callback_url, + state_metadata=request.state_metadata, + initiated_by_api_key_id=api_key.id, + ) + + # Build login URL + login_url = handler.get_login_url( + request.scopes, state_token, code_challenge=code_challenge + ) + + # Calculate expiration (10 minutes from now) + from datetime import datetime, timedelta, timezone + + expires_at = int((datetime.now(timezone.utc) + timedelta(minutes=10)).timestamp()) + + return OAuthInitiateResponse( + login_url=login_url, + state_token=state_token, + expires_at=expires_at, + ) + + +@integrations_router.post( + "/{provider}/oauth/complete", + response_model=OAuthCompleteResponse, + summary="Complete OAuth flow", +) +async def complete_oauth( + provider: Annotated[str, Path(title="The OAuth provider")], + request: OAuthCompleteRequest, + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.MANAGE_INTEGRATIONS) + ), +) -> OAuthCompleteResponse: + """ + Complete an OAuth flow by exchanging the authorization code for tokens. + + This endpoint should be called after the user has authorized the application + and been redirected back to the external app's callback URL with an + authorization code. + """ + # Verify state token + valid_state = await creds_manager.store.verify_state_token( + api_key.user_id, request.state_token, provider + ) + + if not valid_state: + logger.warning(f"Invalid or expired state token for provider {provider}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired state token", + ) + + # Verify this is an external flow (callback_url must be set) + if not valid_state.callback_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="State token was not created for external OAuth flow", + ) + + # Get OAuth handler with the original callback URL + handler = _get_oauth_handler_for_external(provider, valid_state.callback_url) + + try: + scopes = valid_state.scopes + scopes = handler.handle_default_scopes(scopes) + + credentials = await handler.exchange_code_for_tokens( + request.code, scopes, valid_state.code_verifier + ) + + # Handle Linear's space-separated scopes + if len(credentials.scopes) == 1 and " " in credentials.scopes[0]: + credentials.scopes = credentials.scopes[0].split(" ") + + # Check scope mismatch + if not set(scopes).issubset(set(credentials.scopes)): + logger.warning( + f"Granted scopes {credentials.scopes} for provider {provider} " + f"do not include all requested scopes {scopes}" + ) + + except Exception as e: + logger.error(f"OAuth2 Code->Token exchange failed for provider {provider}: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"OAuth2 callback failed to exchange code for tokens: {str(e)}", + ) + + # Store credentials + await creds_manager.create(api_key.user_id, credentials) + + logger.info(f"Successfully completed external OAuth for provider {provider}") + + return OAuthCompleteResponse( + credentials_id=credentials.id, + provider=credentials.provider, + type=credentials.type, + title=credentials.title, + scopes=credentials.scopes, + username=credentials.username, + state_metadata=valid_state.state_metadata, + ) + + +@integrations_router.get("/credentials", response_model=list[CredentialSummary]) +async def list_credentials( + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.READ_INTEGRATIONS) + ), +) -> list[CredentialSummary]: + """ + List all credentials for the authenticated user. + + Returns metadata about each credential without exposing sensitive tokens. + """ + credentials = await creds_manager.store.get_all_creds(api_key.user_id) + return [ + CredentialSummary( + id=cred.id, + provider=cred.provider, + type=cred.type, + title=cred.title, + scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None, + username=cred.username if isinstance(cred, OAuth2Credentials) else None, + host=cred.host if isinstance(cred, HostScopedCredentials) else None, + ) + for cred in credentials + ] + + +@integrations_router.get( + "/{provider}/credentials", response_model=list[CredentialSummary] +) +async def list_credentials_by_provider( + provider: Annotated[str, Path(title="The provider to list credentials for")], + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.READ_INTEGRATIONS) + ), +) -> list[CredentialSummary]: + """ + List credentials for a specific provider. + """ + credentials = await creds_manager.store.get_creds_by_provider( + api_key.user_id, provider + ) + return [ + CredentialSummary( + id=cred.id, + provider=cred.provider, + type=cred.type, + title=cred.title, + scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None, + username=cred.username if isinstance(cred, OAuth2Credentials) else None, + host=cred.host if isinstance(cred, HostScopedCredentials) else None, + ) + for cred in credentials + ] + + +@integrations_router.post( + "/{provider}/credentials", + response_model=CreateCredentialResponse, + status_code=status.HTTP_201_CREATED, + summary="Create credentials", +) +async def create_credential( + provider: Annotated[str, Path(title="The provider to create credentials for")], + request: Union[ + CreateAPIKeyCredentialRequest, + CreateUserPasswordCredentialRequest, + CreateHostScopedCredentialRequest, + ] = Body(..., discriminator="type"), + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.MANAGE_INTEGRATIONS) + ), +) -> CreateCredentialResponse: + """ + Create non-OAuth credentials for a provider. + + Supports creating: + - API key credentials (type: "api_key") + - Username/password credentials (type: "user_password") + - Host-scoped credentials (type: "host_scoped") + + For OAuth credentials, use the OAuth initiate/complete flow instead. + """ + # Validate provider exists + all_providers = get_all_provider_names() + if provider not in all_providers: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider}' not found", + ) + + # Create the appropriate credential type + credentials: Credentials + if request.type == "api_key": + credentials = APIKeyCredentials( + provider=provider, + api_key=SecretStr(request.api_key), + title=request.title, + expires_at=request.expires_at, + ) + elif request.type == "user_password": + credentials = UserPasswordCredentials( + provider=provider, + username=SecretStr(request.username), + password=SecretStr(request.password), + title=request.title, + ) + elif request.type == "host_scoped": + # Convert string headers to SecretStr + secret_headers = {k: SecretStr(v) for k, v in request.headers.items()} + credentials = HostScopedCredentials( + provider=provider, + host=request.host, + headers=secret_headers, + title=request.title, + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported credential type: {request.type}", + ) + + # Store credentials + try: + await creds_manager.create(api_key.user_id, credentials) + except Exception as e: + logger.error(f"Failed to store credentials: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to store credentials: {str(e)}", + ) + + logger.info(f"Created {request.type} credentials for provider {provider}") + + return CreateCredentialResponse( + id=credentials.id, + provider=provider, + type=credentials.type, + title=credentials.title, + ) + + +class DeleteCredentialResponse(BaseModel): + """Response model for deleting a credential.""" + + deleted: bool = Field(..., description="Whether the credential was deleted") + credentials_id: str = Field(..., description="ID of the deleted credential") + + +@integrations_router.delete( + "/{provider}/credentials/{cred_id}", + response_model=DeleteCredentialResponse, +) +async def delete_credential( + provider: Annotated[str, Path(title="The provider")], + cred_id: Annotated[str, Path(title="The credential ID to delete")], + api_key: APIKeyInfo = Security( + require_permission(APIKeyPermission.DELETE_INTEGRATIONS) + ), +) -> DeleteCredentialResponse: + """ + Delete a credential. + + Note: This does not revoke the tokens with the provider. For full cleanup, + use the main API's delete endpoint which handles webhook cleanup and + token revocation. + """ + creds = await creds_manager.store.get_creds_by_id(api_key.user_id, cred_id) + if not creds: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Credentials not found" + ) + if creds.provider != provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Credentials do not match the specified provider", + ) + + await creds_manager.delete(api_key.user_id, cred_id) + + return DeleteCredentialResponse(deleted=True, credentials_id=cred_id) diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py b/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py index f8e407cf61..9ba2eab893 100644 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py +++ b/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py @@ -3,6 +3,8 @@ import logging from typing import Any +from pydantic import BaseModel, Field, field_validator + from backend.data.graph import GraphModel from backend.data.model import CredentialsMetaInput from backend.data.user import get_user_by_id @@ -50,6 +52,25 @@ MSG_WHAT_VALUES_TO_USE = ( ) +class RunAgentInput(BaseModel): + """Input parameters for the run_agent tool.""" + + username_agent_slug: str = "" + inputs: dict[str, Any] = Field(default_factory=dict) + use_defaults: bool = False + schedule_name: str = "" + cron: str = "" + timezone: str = "UTC" + + @field_validator( + "username_agent_slug", "schedule_name", "cron", "timezone", mode="before" + ) + @classmethod + def strip_strings(cls, v: Any) -> Any: + """Strip whitespace from string fields.""" + return v.strip() if isinstance(v, str) else v + + class RunAgentTool(BaseTool): """Unified tool for agent operations with automatic state detection. @@ -124,16 +145,11 @@ class RunAgentTool(BaseTool): **kwargs, ) -> ToolResponseBase: """Execute the tool with automatic state detection.""" - agent_slug = kwargs.get("username_agent_slug", "").strip() - inputs = kwargs.get("inputs", {}) - use_defaults = kwargs.get("use_defaults", False) - schedule_name = kwargs.get("schedule_name", "").strip() - cron = kwargs.get("cron", "").strip() - timezone = kwargs.get("timezone", "UTC").strip() + params = RunAgentInput(**kwargs) session_id = session.session_id # Validate agent slug format - if not agent_slug or "/" not in agent_slug: + if not params.username_agent_slug or "/" not in params.username_agent_slug: return ErrorResponse( message="Please provide an agent slug in format 'username/agent-name'", session_id=session_id, @@ -147,16 +163,16 @@ class RunAgentTool(BaseTool): ) # Determine if this is a schedule request - is_schedule = bool(schedule_name or cron) + is_schedule = bool(params.schedule_name or params.cron) try: # Step 1: Fetch agent details (always happens first) - username, agent_name = agent_slug.split("/", 1) + username, agent_name = params.username_agent_slug.split("/", 1) graph, store_agent = await fetch_graph_from_store_slug(username, agent_name) if not graph: return ErrorResponse( - message=f"Agent '{agent_slug}' not found in marketplace", + message=f"Agent '{params.username_agent_slug}' not found in marketplace", session_id=session_id, ) @@ -204,11 +220,11 @@ class RunAgentTool(BaseTool): # Get all available input fields from schema input_properties = graph.input_schema.get("properties", {}) required_fields = set(graph.input_schema.get("required", [])) - provided_inputs = set(inputs.keys()) + provided_inputs = set(params.inputs.keys()) # If agent has inputs but none were provided AND use_defaults is not set, # always show what's available first so user can decide - if input_properties and not provided_inputs and not use_defaults: + if input_properties and not provided_inputs and not params.use_defaults: credentials = extract_credentials_from_schema( graph.credentials_input_schema ) @@ -224,7 +240,7 @@ class RunAgentTool(BaseTool): # Check if required inputs are missing (and not using defaults) missing_inputs = required_fields - provided_inputs - if missing_inputs and not use_defaults: + if missing_inputs and not params.use_defaults: # Return agent details with missing inputs info credentials = extract_credentials_from_schema( graph.credentials_input_schema @@ -249,10 +265,10 @@ class RunAgentTool(BaseTool): session=session, graph=graph, graph_credentials=graph_credentials, - inputs=inputs, - schedule_name=schedule_name, - cron=cron, - timezone=timezone, + inputs=params.inputs, + schedule_name=params.schedule_name, + cron=params.cron, + timezone=params.timezone, ) else: return await self._run_agent( @@ -260,12 +276,12 @@ class RunAgentTool(BaseTool): session=session, graph=graph, graph_credentials=graph_credentials, - inputs=inputs, + inputs=params.inputs, ) except NotFoundError as e: return ErrorResponse( - message=f"Agent '{agent_slug}' not found", + message=f"Agent '{params.username_agent_slug}' not found", error=str(e) if str(e) else "not_found", session_id=session_id, ) diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index ada6d362fe..5196fff410 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -439,6 +439,12 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): '"regex:" to match via regular expression.', ) + external_oauth_callback_origins: List[str] = Field( + default=["http://localhost:3000"], + description="Allowed callback URL origins for external OAuth flows. " + "External apps (like Autopilot) must have their callback URLs start with one of these origins.", + ) + @field_validator("backend_cors_allow_origins") @classmethod def validate_cors_allow_origins(cls, v: List[str]) -> List[str]: diff --git a/autogpt_platform/backend/migrations/20251127144817_add_api_key_integration_permissions/migration.sql b/autogpt_platform/backend/migrations/20251127144817_add_api_key_integration_permissions/migration.sql new file mode 100644 index 0000000000..f3abc99947 --- /dev/null +++ b/autogpt_platform/backend/migrations/20251127144817_add_api_key_integration_permissions/migration.sql @@ -0,0 +1,4 @@ +-- AlterEnum +ALTER TYPE "APIKeyPermission" ADD VALUE 'MANAGE_INTEGRATIONS'; +ALTER TYPE "APIKeyPermission" ADD VALUE 'READ_INTEGRATIONS'; +ALTER TYPE "APIKeyPermission" ADD VALUE 'DELETE_INTEGRATIONS'; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index ad1f484b68..0c29c83673 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -914,6 +914,9 @@ enum APIKeyPermission { READ_BLOCK // Can get block information READ_STORE // Can read store agents and creators USE_TOOLS // Can use chat tools via external API + MANAGE_INTEGRATIONS // Can initiate OAuth flows and complete them + READ_INTEGRATIONS // Can list credentials and providers + DELETE_INTEGRATIONS // Can delete credentials } model APIKey { diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 923af9f3c3..15a1453b5c 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -5343,7 +5343,10 @@ "EXECUTE_BLOCK", "READ_BLOCK", "READ_STORE", - "USE_TOOLS" + "USE_TOOLS", + "MANAGE_INTEGRATIONS", + "READ_INTEGRATIONS", + "DELETE_INTEGRATIONS" ], "title": "APIKeyPermission" },