mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-07 22:33:57 -05:00
feat(platform): Integration management from external api (#11472)
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:
<!-- Put your test plan here: -->
- [x] tested it works
<!-- CURSOR_SUMMARY -->
---
> [!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.
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
249bba9e59. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
634
autogpt_platform/backend/backend/server/external/routes/integrations.py
vendored
Normal file
634
autogpt_platform/backend/backend/server/external/routes/integrations.py
vendored
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -5343,7 +5343,10 @@
|
||||
"EXECUTE_BLOCK",
|
||||
"READ_BLOCK",
|
||||
"READ_STORE",
|
||||
"USE_TOOLS"
|
||||
"USE_TOOLS",
|
||||
"MANAGE_INTEGRATIONS",
|
||||
"READ_INTEGRATIONS",
|
||||
"DELETE_INTEGRATIONS"
|
||||
],
|
||||
"title": "APIKeyPermission"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user