From c5ea5b40601ad80fc0bec3f97eeaa1cfd7f15b58 Mon Sep 17 00:00:00 2001 From: Vikas Date: Fri, 28 Nov 2025 19:00:40 +0530 Subject: [PATCH 01/14] feat: add custom credential service for HTTP API integration Add support for custom credential service allowing Skyvern to integrate with external HTTP APIs for credential management. This provides flexibility for organizations that want to use their own credential management systems. Features: - HTTP API integration with Bearer token authentication - Support for password and credit card credentials - Environment variable configuration (self-hosted) - Organization-specific API configuration (cloud) - UI configuration through Skyvern settings page - Connection testing functionality - Comprehensive error handling and logging Backend changes: - Add CustomCredentialAPIClient for HTTP API communication - Add CustomCredentialVaultService implementing CredentialVaultService interface - Add custom credential API endpoints and configuration schemas - Add CUSTOM credential vault type and database enums - Integrate service into forge app and credential vault registry Frontend changes: - Add CustomCredentialServiceConfigForm React component - Add useCustomCredentialServiceConfig hook with connection testing - Add TypeScript types for API integration - Integrate into Settings page Documentation: - Add comprehensive API documentation in fern/credentials - Add configuration examples and troubleshooting guide - Update README with custom credential service support --- README.md | 1 + .../credentials/custom-credential-service.mdx | 203 ++++++++++++++ fern/credentials/introduction.mdx | 8 + fern/docs.yml | 2 + skyvern-frontend/src/api/types.ts | 23 ++ .../CustomCredentialServiceConfigForm.tsx | 215 ++++++++++++++ .../hooks/useCustomCredentialServiceConfig.ts | 154 ++++++++++ .../src/routes/settings/Settings.tsx | 12 + skyvern/config.py | 4 + skyvern/forge/forge_app.py | 14 + .../forge/sdk/api/custom_credential_client.py | 262 ++++++++++++++++++ skyvern/forge/sdk/db/enums.py | 1 + skyvern/forge/sdk/routes/credentials.py | 113 ++++++++ skyvern/forge/sdk/schemas/credentials.py | 1 + skyvern/forge/sdk/schemas/organizations.py | 30 ++ .../custom_credential_vault_service.py | 239 ++++++++++++++++ 16 files changed, 1282 insertions(+) create mode 100644 fern/credentials/custom-credential-service.mdx create mode 100644 skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx create mode 100644 skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts create mode 100644 skyvern/forge/sdk/api/custom_credential_client.py create mode 100644 skyvern/forge/sdk/services/credential/custom_credential_vault_service.py diff --git a/README.md b/README.md index 5f65be1080..41017b8b96 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ Examples include: ### Password Manager Integrations Skyvern currently supports the following password manager integrations: - [x] Bitwarden +- [x] Custom Credential Service (HTTP API) - [ ] 1Password - [ ] LastPass diff --git a/fern/credentials/custom-credential-service.mdx b/fern/credentials/custom-credential-service.mdx new file mode 100644 index 0000000000..73a4beec0d --- /dev/null +++ b/fern/credentials/custom-credential-service.mdx @@ -0,0 +1,203 @@ +--- +title: Custom Credential Service +subtitle: Integrate your own HTTP API for credential management +slug: credentials/custom-credential-service +--- + +Skyvern supports integrating with custom HTTP APIs for credential management, allowing you to use your existing credential infrastructure instead of third-party services. + +## Overview + +The custom credential service feature enables Skyvern to store and retrieve credentials from external HTTP APIs. This is perfect for organizations that: + +- Have existing credential management systems +- Need to maintain credentials in their own infrastructure +- Want to integrate with proprietary credential vaults +- Require custom authentication flows + +## API Contract + +Your custom credential service must implement these HTTP endpoints: + +### Create Credential +```http +POST {API_BASE_URL} +Authorization: Bearer {API_TOKEN} +Content-Type: application/json + +{ + "name": "My Credential", + "type": "password", + "username": "user@example.com", + "password": "secure_password", + "totp": "JBSWY3DPEHPK3PXP", + "totp_type": "authenticator" +} +``` + +**Response:** +```json +{ + "id": "cred_123456" +} +``` + +### Get Credential +```http +GET {API_BASE_URL}/{credential_id} +Authorization: Bearer {API_TOKEN} +``` + +**Response:** +```json +{ + "type": "password", + "username": "user@example.com", + "password": "secure_password", + "totp": "JBSWY3DPEHPK3PXP", + "totp_type": "authenticator" +} +``` + +### Delete Credential +```http +DELETE {API_BASE_URL}/{credential_id} +Authorization: Bearer {API_TOKEN} +``` + +**Response:** HTTP 200 (empty body acceptable) + +## Configuration + +### Environment Variables (Self-hosted) + +Set these environment variables in your `.env` file: + +```bash +CREDENTIAL_VAULT_TYPE=custom +CUSTOM_CREDENTIAL_API_BASE_URL=https://credentials.company.com/api/v1/credentials +CUSTOM_CREDENTIAL_API_TOKEN=your_api_token_here +``` + +### Organization Configuration (Cloud) + +Use the Skyvern API to configure per-organization: + +```http +POST /api/v1/credentials/custom_credential/create +Authorization: Bearer {SKYVERN_API_KEY} +Content-Type: application/json + +{ + "config": { + "api_base_url": "https://credentials.company.com/api/v1/credentials", + "api_token": "your_api_token_here" + } +} +``` + +### UI Configuration + +1. Navigate to **Settings** → **Custom Credential Service** +2. Enter your API Base URL and API Token +3. Click **Test Connection** to verify connectivity +4. Click **Update Configuration** to save + +## Example Implementation + +Here's a minimal example using FastAPI: + +```python +from fastapi import FastAPI, HTTPException, Depends, Header +from pydantic import BaseModel +from typing import Optional +import uuid + +app = FastAPI() + +# In-memory storage (use a real database in production) +credentials_store = {} + +class CreateCredentialRequest(BaseModel): + name: str + type: str # "password" or "credit_card" + username: Optional[str] = None + password: Optional[str] = None + totp: Optional[str] = None + totp_type: Optional[str] = None + +class CredentialResponse(BaseModel): + id: str + +def verify_token(authorization: str = Header(...)): + if not authorization.startswith("Bearer "): + raise HTTPException(401, "Invalid authorization header") + + token = authorization.split("Bearer ")[1] + if token != "your_expected_api_token": + raise HTTPException(401, "Invalid API token") + +@app.post("/api/v1/credentials", response_model=CredentialResponse) +async def create_credential( + request: CreateCredentialRequest, + _: None = Depends(verify_token) +): + credential_id = f"cred_{uuid.uuid4().hex[:12]}" + credentials_store[credential_id] = request.dict() + return CredentialResponse(id=credential_id) + +@app.get("/api/v1/credentials/{credential_id}") +async def get_credential( + credential_id: str, + _: None = Depends(verify_token) +): + if credential_id not in credentials_store: + raise HTTPException(404, "Credential not found") + return credentials_store[credential_id] + +@app.delete("/api/v1/credentials/{credential_id}") +async def delete_credential( + credential_id: str, + _: None = Depends(verify_token) +): + if credential_id not in credentials_store: + raise HTTPException(404, "Credential not found") + del credentials_store[credential_id] + return {"status": "deleted"} +``` + +## Security Considerations + +- API tokens are stored encrypted in the database +- Bearer tokens are transmitted over HTTPS only +- Frontend masks sensitive tokens in the UI +- API credentials are never logged in plaintext +- Implement proper rate limiting and authentication in your API + +## Troubleshooting + +### Connection Test Fails + +1. Verify API base URL is correct and accessible +2. Check that API token is valid +3. Check firewall and network connectivity +4. Note: Connection test only verifies basic connectivity - 404/405 responses are considered successful if the server is reachable + +### Credentials Not Created + +1. Review API logs for authentication errors +2. Verify request format matches expected schema +3. Ensure API returns `id` in response + +### Environment Configuration Not Working + +1. Restart Skyvern after setting environment variables +2. Verify `CREDENTIAL_VAULT_TYPE=custom` is set +3. Check both URL and token are provided + +## Limitations + +- Connection testing verifies network connectivity and basic API reachability but not full endpoint implementation +- API must support all required endpoints (no partial implementation) +- Token rotation requires manual reconfiguration +- No built-in credential synchronization between vaults diff --git a/fern/credentials/introduction.mdx b/fern/credentials/introduction.mdx index f5c9f44a80..3964157f5f 100644 --- a/fern/credentials/introduction.mdx +++ b/fern/credentials/introduction.mdx @@ -57,6 +57,7 @@ If you have your own password manager, Skyvern can integrate with it. Skyvern ca **Supported password manager types**: - Bitwarden +- Custom Credential Service (HTTP API) - 1Password Integration (Private beta) **Coming Soon**: @@ -88,6 +89,13 @@ Contact [Skyvern Support](mailto:support@skyvern.com) if you want access to the > (coming soon) Securely manage your passwords with LastPass + + Integrate your own HTTP API for credential management + ; + +export function CustomCredentialServiceConfigForm() { + const [showApiToken, setShowApiToken] = useState(false); + const { + customCredentialServiceAuthToken, + parsedConfig, + isLoading, + createOrUpdateConfig, + isUpdating, + testConnection, + isTesting, + } = useCustomCredentialServiceConfig(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + config: parsedConfig || { + api_base_url: "", + api_token: "", + }, + }, + }); + + const onSubmit = (data: FormData) => { + createOrUpdateConfig(data); + }; + + const handleTestConnection = () => { + const formData = form.getValues(); + testConnection(formData.config); + }; + + const toggleApiTokenVisibility = () => { + setShowApiToken((v) => !v); + }; + + useEffect(() => { + if (parsedConfig) { + form.reset({ config: parsedConfig }); + } + }, [parsedConfig, form]); + + return ( +
+
+
+

+ Custom Credential Service +

+

+ Configure your custom HTTP API for credential management. Your API should support the standard CRUD operations. +

+
+ {customCredentialServiceAuthToken && ( +
+ Status: + + {customCredentialServiceAuthToken.valid ? "Active" : "Inactive"} + +
+ )} +
+ +
+ + ( + + API Base URL + + The base URL of your custom credential service API (e.g., https://credentials.company.com/api/v1) + +
+ + + + +
+ +
+ )} + /> + + ( + + API Token + + Bearer token for authenticating with your custom credential service + +
+ + + + +
+ +
+ )} + /> + +
+ + + + + {customCredentialServiceAuthToken && ( +
+ Last updated:{" "} + {new Date( + customCredentialServiceAuthToken.modified_at, + ).toLocaleDateString()} +
+ )} +
+ + + + {customCredentialServiceAuthToken && ( +
+

Configuration Information

+
+
ID: {customCredentialServiceAuthToken.id}
+
Type: {customCredentialServiceAuthToken.token_type}
+
+ Created:{" "} + {new Date( + customCredentialServiceAuthToken.created_at, + ).toLocaleDateString()} +
+ {parsedConfig && ( +
+
Configured API URL: {parsedConfig.api_base_url}
+
Token (masked): {parsedConfig.api_token.slice(0, 8)}...
+
+ )} +
+
+ )} +
+ ); +} diff --git a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts new file mode 100644 index 0000000000..30b4147a7e --- /dev/null +++ b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts @@ -0,0 +1,154 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "./useCredentialGetter"; +import { + CustomCredentialServiceConfigResponse, + CustomCredentialServiceOrganizationAuthToken, + CreateCustomCredentialServiceConfigRequest, + CustomCredentialServiceConfig, +} from "@/api/types"; +import { useToast } from "@/components/ui/use-toast"; + +export function useCustomCredentialServiceConfig() { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data: customCredentialServiceAuthToken, isLoading } = + useQuery({ + queryKey: ["customCredentialServiceAuthToken"], + queryFn: async () => { + const client = await getClient(credentialGetter, "sans-api-v1"); + return await client + .get("/credentials/custom_credential/get") + .then((response) => response.data.token) + .catch(() => null); + }, + }); + + // Parse the configuration from the stored token + const parsedConfig: CustomCredentialServiceConfig | null = (() => { + if (!customCredentialServiceAuthToken?.token) return null; + + try { + return JSON.parse(customCredentialServiceAuthToken.token); + } catch { + return null; + } + })(); + + const createOrUpdateConfigMutation = useMutation({ + mutationFn: async (data: CreateCustomCredentialServiceConfigRequest) => { + const client = await getClient(credentialGetter, "sans-api-v1"); + return await client + .post("/credentials/custom_credential/create", data) + .then( + (response) => response.data as CustomCredentialServiceConfigResponse, + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["customCredentialServiceAuthToken"], + }); + toast({ + title: "Success", + description: "Custom credential service configuration updated successfully", + }); + }, + onError: (error: unknown) => { + const message = + (error as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail || + (error as Error)?.message || + "Failed to update custom credential service configuration"; + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + }); + + const testConnectionMutation = useMutation({ + mutationFn: async (config: CustomCredentialServiceConfig) => { + // Test the connection by making a request to the base API URL + const testUrl = config.api_base_url.replace(/\/$/, ''); + + try { + // Create an AbortController for timeout handling + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(testUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.api_token}`, + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Consider connection successful if we can reach the API and get any response + // (even 404, 405, etc. - these indicate the server is reachable) + if (response.status >= 200 && response.status < 500) { + if (response.status === 401 || response.status === 403) { + return { + success: true, + message: "Connection successful (authentication may need verification)" + }; + } + if (response.status === 404 || response.status === 405) { + return { + success: true, + message: "Connection successful (API endpoint reachable)" + }; + } + return { success: true, message: "Connection successful" }; + } + + // Only treat 5xx errors as connection failures + throw new Error(`Server error: HTTP ${response.status}`); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Connection timeout after 10 seconds'); + } + + // Network errors, DNS failures, etc. + if (error instanceof TypeError && error.message.includes('fetch')) { + throw new Error('Network error: Cannot reach the API server. Check the URL and network connectivity.'); + } + + throw new Error( + `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }, + onSuccess: (data) => { + toast({ + title: "Connection Test Successful", + description: data.message, + }); + }, + onError: (error: unknown) => { + const message = + (error as Error)?.message || "Connection test failed"; + toast({ + title: "Connection Test Failed", + description: message, + variant: "destructive", + }); + }, + }); + + return { + customCredentialServiceAuthToken, + parsedConfig, + isLoading, + createOrUpdateConfig: createOrUpdateConfigMutation.mutate, + isUpdating: createOrUpdateConfigMutation.isPending, + testConnection: testConnectionMutation.mutate, + isTesting: testConnectionMutation.isPending, + }; +} diff --git a/skyvern-frontend/src/routes/settings/Settings.tsx b/skyvern-frontend/src/routes/settings/Settings.tsx index bb7df454f9..686f89d1ac 100644 --- a/skyvern-frontend/src/routes/settings/Settings.tsx +++ b/skyvern-frontend/src/routes/settings/Settings.tsx @@ -18,6 +18,7 @@ import { getRuntimeApiKey } from "@/util/env"; import { HiddenCopyableInput } from "@/components/ui/hidden-copyable-input"; import { OnePasswordTokenForm } from "@/components/OnePasswordTokenForm"; import { AzureClientSecretCredentialTokenForm } from "@/components/AzureClientSecretCredentialTokenForm"; +import { CustomCredentialServiceConfigForm } from "@/components/CustomCredentialServiceConfigForm"; function Settings() { const { environment, organization, setEnvironment, setOrganization } = @@ -97,6 +98,17 @@ function Settings() {
+ + + Custom Credential Service + + Configure your custom HTTP API for credential management. + + + + + + ); } diff --git a/skyvern/config.py b/skyvern/config.py index e1d5aa8ba9..956f3f8c70 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -340,6 +340,10 @@ class Settings(BaseSettings): # The Azure Key Vault name to store credentials AZURE_CREDENTIAL_VAULT: str | None = None + # Custom Credential Service Settings + CUSTOM_CREDENTIAL_API_BASE_URL: str | None = None + CUSTOM_CREDENTIAL_API_TOKEN: str | None = None + # Skyvern Auth Bitwarden Settings SKYVERN_AUTH_BITWARDEN_CLIENT_ID: str | None = None SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET: str | None = None diff --git a/skyvern/forge/forge_app.py b/skyvern/forge/forge_app.py index a38a9b14e2..4b87173ccb 100644 --- a/skyvern/forge/forge_app.py +++ b/skyvern/forge/forge_app.py @@ -24,9 +24,11 @@ from skyvern.forge.sdk.experimentation.providers import BaseExperimentationProvider, NoOpExperimentationProvider from skyvern.forge.sdk.schemas.credentials import CredentialVaultType from skyvern.forge.sdk.schemas.organizations import AzureClientSecretCredential, Organization +from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient from skyvern.forge.sdk.services.credential.azure_credential_vault_service import AzureCredentialVaultService from skyvern.forge.sdk.services.credential.bitwarden_credential_service import BitwardenCredentialVaultService from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService +from skyvern.forge.sdk.services.credential.custom_credential_vault_service import CustomCredentialVaultService from skyvern.forge.sdk.settings_manager import SettingsManager from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager from skyvern.forge.sdk.workflow.service import WorkflowService @@ -68,6 +70,7 @@ class ForgeApp: PERSISTENT_SESSIONS_MANAGER: PersistentSessionsManager BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None + CUSTOM_CREDENTIAL_VAULT_SERVICE: CustomCredentialVaultService | None CREDENTIAL_VAULT_SERVICES: dict[str, CredentialVaultService | None] scrape_exclude: ScrapeExcludeFunc | None authentication_function: Callable[[str], Awaitable[Organization]] | None @@ -190,9 +193,20 @@ def create_forge_app() -> ForgeApp: if settings.AZURE_CREDENTIAL_VAULT else None ) + app.CUSTOM_CREDENTIAL_VAULT_SERVICE = ( + CustomCredentialVaultService( + CustomCredentialAPIClient( + api_base_url=settings.CUSTOM_CREDENTIAL_API_BASE_URL, # type: ignore + api_token=settings.CUSTOM_CREDENTIAL_API_TOKEN, # type: ignore + ) + ) + if settings.CUSTOM_CREDENTIAL_API_BASE_URL and settings.CUSTOM_CREDENTIAL_API_TOKEN + else CustomCredentialVaultService() # Create service without client for organization-based configuration + ) app.CREDENTIAL_VAULT_SERVICES = { CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE, + CredentialVaultType.CUSTOM: app.CUSTOM_CREDENTIAL_VAULT_SERVICE, } app.scrape_exclude = None diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py new file mode 100644 index 0000000000..2fb535e99f --- /dev/null +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -0,0 +1,262 @@ +import structlog +from typing import Any, Dict + +from skyvern.exceptions import HttpException +from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_delete, aiohttp_get_json, aiohttp_post +from skyvern.forge.sdk.schemas.credentials import ( + CreateCredentialRequest, + CreditCardCredential, + PasswordCredential, + CredentialItem, + CredentialType, +) + +LOG = structlog.get_logger() + + +class CustomCredentialAPIClient: + """HTTP client for interacting with custom credential service APIs.""" + + def __init__(self, api_base_url: str, api_token: str): + """ + Initialize the custom credential API client. + + Args: + api_base_url: Base URL for the custom credential API + api_token: Bearer token for authentication + """ + self.api_base_url = api_base_url.rstrip("/") + self.api_token = api_token + + def _get_auth_headers(self) -> Dict[str, str]: + """Get headers for API authentication.""" + return { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + + def _credential_to_api_payload(self, credential: PasswordCredential | CreditCardCredential) -> Dict[str, Any]: + """Convert Skyvern credential to API payload format.""" + if isinstance(credential, PasswordCredential): + return { + "type": "password", + "username": credential.username, + "password": credential.password, + "totp": credential.totp, + "totp_type": credential.totp_type, + } + elif isinstance(credential, CreditCardCredential): + return { + "type": "credit_card", + "card_holder_name": credential.card_holder_name, + "card_number": credential.card_number, + "card_exp_month": credential.card_exp_month, + "card_exp_year": credential.card_exp_year, + "card_cvv": credential.card_cvv, + "card_brand": credential.card_brand, + } + else: + raise ValueError(f"Unsupported credential type: {type(credential)}") + + def _api_response_to_credential(self, credential_data: Dict[str, Any], name: str, item_id: str) -> CredentialItem: + """Convert API response to Skyvern CredentialItem.""" + credential_type = credential_data.get("type") + + if credential_type == "password": + credential = PasswordCredential( + username=credential_data["username"], + password=credential_data["password"], + totp=credential_data.get("totp"), + totp_type=credential_data.get("totp_type", "none"), + ) + return CredentialItem( + item_id=item_id, + credential=credential, + name=name, + credential_type=CredentialType.PASSWORD, + ) + elif credential_type == "credit_card": + credential = CreditCardCredential( + card_holder_name=credential_data["card_holder_name"], + card_number=credential_data["card_number"], + card_exp_month=credential_data["card_exp_month"], + card_exp_year=credential_data["card_exp_year"], + card_cvv=credential_data["card_cvv"], + card_brand=credential_data["card_brand"], + ) + return CredentialItem( + item_id=item_id, + credential=credential, + name=name, + credential_type=CredentialType.CREDIT_CARD, + ) + else: + raise ValueError(f"Unsupported credential type from API: {credential_type}") + + async def create_credential(self, name: str, credential: PasswordCredential | CreditCardCredential) -> str: + """ + Create a credential using the custom API. + + Args: + name: Name of the credential + credential: Credential data to store + + Returns: + The credential ID returned by the API + + Raises: + HttpException: If the API request fails + """ + url = f"{self.api_base_url}" + headers = self._get_auth_headers() + + payload = { + "name": name, + **self._credential_to_api_payload(credential), + } + + LOG.info( + "Creating credential via custom API", + url=url, + name=name, + credential_type=type(credential).__name__, + ) + + try: + response = await aiohttp_post( + url=url, + data=payload, + headers=headers, + raise_exception=True, + ) + + if not response: + raise HttpException(500, f"Empty response from custom credential API: {url}") + + # Extract credential ID from response + credential_id = response.get("id") + if not credential_id: + LOG.error( + "Custom credential API response missing id field", + url=url, + response=response, + ) + raise HttpException(500, "Invalid response format from custom credential API") + + LOG.info( + "Successfully created credential via custom API", + url=url, + name=name, + credential_id=credential_id, + ) + + return str(credential_id) + + except HttpException: + raise + except Exception as e: + LOG.error( + "Failed to create credential via custom API", + url=url, + name=name, + error=str(e), + exc_info=True, + ) + raise HttpException(500, f"Failed to create credential via custom API: {str(e)}") + + async def get_credential(self, credential_id: str, name: str) -> CredentialItem: + """ + Get a credential using the custom API. + + Args: + credential_id: ID of the credential to retrieve + name: Name of the credential (for constructing CredentialItem) + + Returns: + The credential data + + Raises: + HttpException: If the API request fails + """ + url = f"{self.api_base_url}/{credential_id}" + headers = self._get_auth_headers() + + LOG.info( + "Retrieving credential via custom API", + url=url, + credential_id=credential_id, + ) + + try: + response = await aiohttp_get_json( + url=url, + headers=headers, + raise_exception=True, + ) + + if not response: + raise HttpException(404, f"Credential not found in custom API: {credential_id}") + + LOG.info( + "Successfully retrieved credential via custom API", + url=url, + credential_id=credential_id, + ) + + return self._api_response_to_credential(response, name, credential_id) + + except HttpException: + raise + except Exception as e: + LOG.error( + "Failed to retrieve credential via custom API", + url=url, + credential_id=credential_id, + error=str(e), + exc_info=True, + ) + raise HttpException(500, f"Failed to retrieve credential via custom API: {str(e)}") + + async def delete_credential(self, credential_id: str) -> None: + """ + Delete a credential using the custom API. + + Args: + credential_id: ID of the credential to delete + + Raises: + HttpException: If the API request fails + """ + url = f"{self.api_base_url}/{credential_id}" + headers = self._get_auth_headers() + + LOG.info( + "Deleting credential via custom API", + url=url, + credential_id=credential_id, + ) + + try: + await aiohttp_delete( + url=url, + headers=headers, + raise_exception=True, + ) + + LOG.info( + "Successfully deleted credential via custom API", + url=url, + credential_id=credential_id, + ) + + except HttpException: + raise + except Exception as e: + LOG.error( + "Failed to delete credential via custom API", + url=url, + credential_id=credential_id, + error=str(e), + exc_info=True, + ) + raise HttpException(500, f"Failed to delete credential via custom API: {str(e)}") diff --git a/skyvern/forge/sdk/db/enums.py b/skyvern/forge/sdk/db/enums.py index 1718358d3d..de22712587 100644 --- a/skyvern/forge/sdk/db/enums.py +++ b/skyvern/forge/sdk/db/enums.py @@ -5,6 +5,7 @@ class OrganizationAuthTokenType(StrEnum): api = "api" onepassword_service_account = "onepassword_service_account" azure_client_secret_credential = "azure_client_secret_credential" + custom_credential_service = "custom_credential_service" class TaskType(StrEnum): diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index d328c3de12..3a07020b17 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -33,6 +33,8 @@ CreateAzureClientSecretCredentialRequest, CreateOnePasswordTokenRequest, CreateOnePasswordTokenResponse, + CustomCredentialServiceConfigResponse, + CreateCustomCredentialServiceConfigRequest, Organization, ) from skyvern.forge.sdk.schemas.totp_codes import OTPType, TOTPCode, TOTPCodeCreate @@ -616,6 +618,113 @@ async def update_azure_client_secret_credential( ) +@base_router.get( + "/credentials/custom_credential/get", + response_model=CustomCredentialServiceConfigResponse, + summary="Get Custom Credential Service Configuration", + description="Retrieves the current custom credential service configuration for the organization.", + include_in_schema=False, +) +@base_router.get( + "/credentials/custom_credential/get/", + response_model=CustomCredentialServiceConfigResponse, + include_in_schema=False, +) +async def get_custom_credential_service_config( + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CustomCredentialServiceConfigResponse: + """ + Get the current custom credential service configuration for the organization. + """ + try: + auth_token = await app.DATABASE.get_valid_org_auth_token( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service.value, + ) + if not auth_token: + raise HTTPException( + status_code=404, + detail="No custom credential service configuration found for this organization", + ) + + return CustomCredentialServiceConfigResponse(token=auth_token) + + except HTTPException: + raise + except Exception as e: + LOG.error( + "Failed to get custom credential service configuration", + organization_id=current_org.organization_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to get custom credential service configuration: {str(e)}", + ) + + +@base_router.post( + "/credentials/custom_credential/create", + response_model=CustomCredentialServiceConfigResponse, + summary="Create or update Custom Credential Service Configuration", + description="Creates or updates a custom credential service configuration for the current organization. Only one valid configuration is allowed per organization.", + include_in_schema=False, +) +@base_router.post( + "/credentials/custom_credential/create/", + response_model=CustomCredentialServiceConfigResponse, + include_in_schema=False, +) +async def update_custom_credential_service_config( + request: CreateCustomCredentialServiceConfigRequest, + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CustomCredentialServiceConfigResponse: + """ + Create or update a custom credential service configuration for the current organization. + + This endpoint ensures only one valid custom credential service configuration exists per organization. + If a valid configuration already exists, it will be invalidated before creating the new one. + """ + try: + # Invalidate any existing valid custom credential service configuration for this organization + await app.DATABASE.invalidate_org_auth_tokens( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service, + ) + + # Store the configuration as JSON in the token field + import json + config_json = json.dumps(request.config.model_dump()) + + # Create the new configuration + auth_token = await app.DATABASE.create_org_auth_token( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service, + token=config_json, + ) + + LOG.info( + "Created or updated custom credential service configuration", + organization_id=current_org.organization_id, + token_id=auth_token.id, + ) + + return CustomCredentialServiceConfigResponse(token=auth_token) + + except Exception as e: + LOG.error( + "Failed to create or update custom credential service configuration", + organization_id=current_org.organization_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to create or update custom credential service configuration: {str(e)}", + ) + + async def _get_credential_vault_service() -> CredentialVaultService: if settings.CREDENTIAL_VAULT_TYPE == CredentialVaultType.BITWARDEN: return app.BITWARDEN_CREDENTIAL_VAULT_SERVICE @@ -623,6 +732,10 @@ async def _get_credential_vault_service() -> CredentialVaultService: if not app.AZURE_CREDENTIAL_VAULT_SERVICE: raise HTTPException(status_code=400, detail="Azure Vault credential is not supported") return app.AZURE_CREDENTIAL_VAULT_SERVICE + elif settings.CREDENTIAL_VAULT_TYPE == CredentialVaultType.CUSTOM: + if not app.CUSTOM_CREDENTIAL_VAULT_SERVICE: + raise HTTPException(status_code=400, detail="Custom credential vault is not supported") + return app.CUSTOM_CREDENTIAL_VAULT_SERVICE else: raise HTTPException(status_code=400, detail="Credential storage not supported") diff --git a/skyvern/forge/sdk/schemas/credentials.py b/skyvern/forge/sdk/schemas/credentials.py index 0036ffe6b5..adb223380c 100644 --- a/skyvern/forge/sdk/schemas/credentials.py +++ b/skyvern/forge/sdk/schemas/credentials.py @@ -7,6 +7,7 @@ class CredentialVaultType(StrEnum): BITWARDEN = "bitwarden" AZURE_VAULT = "azure_vault" + CUSTOM = "custom" class CredentialType(StrEnum): diff --git a/skyvern/forge/sdk/schemas/organizations.py b/skyvern/forge/sdk/schemas/organizations.py index faaf20f887..36ef7dd62a 100644 --- a/skyvern/forge/sdk/schemas/organizations.py +++ b/skyvern/forge/sdk/schemas/organizations.py @@ -80,6 +80,36 @@ class CreateAzureClientSecretCredentialRequest(BaseModel): credential: AzureClientSecretCredential +class CustomCredentialServiceConfig(BaseModel): + """Configuration for custom credential service.""" + + api_base_url: str = Field( + ..., + description="Base URL for the custom credential API", + examples=["https://credentials.company.com/api/v1/credentials"], + ) + api_token: str = Field( + ..., + description="API token for authenticating with the custom credential service", + examples=["your_api_token_here"], + ) + + +class CustomCredentialServiceConfigResponse(BaseModel): + """Response model for custom credential service operations.""" + + token: OrganizationAuthToken = Field( + ..., + description="The created or updated custom credential service configuration", + ) + + +class CreateCustomCredentialServiceConfigRequest(BaseModel): + """Request model for creating or updating custom credential service configuration.""" + + config: CustomCredentialServiceConfig + + class GetOrganizationsResponse(BaseModel): organizations: list[Organization] diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py new file mode 100644 index 0000000000..8831ae9571 --- /dev/null +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -0,0 +1,239 @@ +import json +import structlog + +from skyvern.forge import app +from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient +from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType +from skyvern.forge.sdk.schemas.credentials import ( + CreateCredentialRequest, + Credential, + CredentialItem, + CredentialVaultType, +) +from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService + +LOG = structlog.get_logger() + + +class CustomCredentialVaultService(CredentialVaultService): + """Custom credential vault service that uses HTTP API for storing credentials.""" + + def __init__(self, client: CustomCredentialAPIClient | None = None): + """ + Initialize the custom credential vault service. + + Args: + client: HTTP client for the custom credential API (optional, created dynamically if not provided) + """ + self._client = client + + async def _get_client_for_organization(self, organization_id: str) -> CustomCredentialAPIClient: + """ + Get or create a CustomCredentialAPIClient for the given organization. + + Args: + organization_id: ID of the organization + + Returns: + Configured API client for the organization + + Raises: + Exception: If no configuration is found for the organization + """ + # If we have a global client (from environment variables), use it + if self._client: + return self._client + + # Otherwise, get organization-specific configuration + try: + auth_token = await app.DATABASE.get_valid_org_auth_token( + organization_id=organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service.value, + ) + + if not auth_token: + raise Exception(f"Custom credential service not configured for organization {organization_id}") + + # Parse the stored configuration + config_data = json.loads(auth_token.token) + + # Create and return the API client + return CustomCredentialAPIClient( + api_base_url=config_data["api_base_url"], + api_token=config_data["api_token"], + ) + + except json.JSONDecodeError as e: + LOG.error( + "Failed to parse custom credential service configuration", + organization_id=organization_id, + error=str(e), + ) + raise Exception(f"Invalid custom credential service configuration for organization {organization_id}") + except Exception as e: + LOG.error( + "Failed to get custom credential service configuration", + organization_id=organization_id, + error=str(e), + exc_info=True, + ) + raise + + async def create_credential(self, organization_id: str, data: CreateCredentialRequest) -> Credential: + """ + Create a new credential in the custom vault and database. + + Args: + organization_id: ID of the organization + data: Request data containing credential information + + Returns: + The created credential record + """ + LOG.info( + "Creating credential in custom vault", + organization_id=organization_id, + name=data.name, + credential_type=data.credential_type, + ) + + try: + # Get the API client for this organization + client = await self._get_client_for_organization(organization_id) + + # Create credential in the external API + item_id = await client.create_credential( + name=data.name, + credential=data.credential, + ) + + # Create record in Skyvern database + credential = await self._create_db_credential( + organization_id=organization_id, + data=data, + item_id=item_id, + vault_type=CredentialVaultType.CUSTOM, + ) + + LOG.info( + "Successfully created credential in custom vault", + organization_id=organization_id, + credential_id=credential.credential_id, + item_id=item_id, + ) + + return credential + + except Exception as e: + LOG.error( + "Failed to create credential in custom vault", + organization_id=organization_id, + name=data.name, + credential_type=data.credential_type, + error=str(e), + exc_info=True, + ) + raise + + async def delete_credential(self, credential: Credential) -> None: + """ + Delete a credential from the custom vault and database. + + Args: + credential: Credential record to delete + """ + LOG.info( + "Deleting credential from custom vault", + organization_id=credential.organization_id, + credential_id=credential.credential_id, + item_id=credential.item_id, + ) + + try: + # Delete from Skyvern database first + await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id) + + # Get the API client for this organization + client = await self._get_client_for_organization(credential.organization_id) + + # Delete from external API + await client.delete_credential(credential.item_id) + + LOG.info( + "Successfully deleted credential from custom vault", + organization_id=credential.organization_id, + credential_id=credential.credential_id, + item_id=credential.item_id, + ) + + except Exception as e: + LOG.error( + "Failed to delete credential from custom vault", + organization_id=credential.organization_id, + credential_id=credential.credential_id, + item_id=credential.item_id, + error=str(e), + exc_info=True, + ) + raise + + async def post_delete_credential_item(self, item_id: str) -> None: + """ + Optional hook for scheduling background cleanup tasks after credential deletion. + For the custom vault service, we don't need additional cleanup since deletion + is synchronous via the API. + + Args: + item_id: ID of the credential item that was deleted + """ + LOG.info( + "Post-delete hook called for custom credential vault", + item_id=item_id, + ) + # No additional cleanup needed for custom vault service + + async def get_credential_item(self, db_credential: Credential) -> CredentialItem: + """ + Retrieve the full credential data from the custom vault. + + Args: + db_credential: Database credential record + + Returns: + Full credential data from the vault + """ + LOG.info( + "Retrieving credential item from custom vault", + organization_id=db_credential.organization_id, + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + ) + + try: + # Get the API client for this organization + client = await self._get_client_for_organization(db_credential.organization_id) + + credential_item = await client.get_credential( + credential_id=db_credential.item_id, + name=db_credential.name, + ) + + LOG.info( + "Successfully retrieved credential item from custom vault", + organization_id=db_credential.organization_id, + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + ) + + return credential_item + + except Exception as e: + LOG.error( + "Failed to retrieve credential item from custom vault", + organization_id=db_credential.organization_id, + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + error=str(e), + exc_info=True, + ) + raise From c48a2cb4d2e1a26404ed29a93dfbfc8d58ba07fb Mon Sep 17 00:00:00 2001 From: Vikas Date: Sun, 30 Nov 2025 09:40:53 +0530 Subject: [PATCH 02/14] fix: improve data consistency in custom credential vault operations - Add cleanup for orphaned external credentials when DB creation fails - Reverse deletion order to prevent data inconsistency (external API first, then DB) - Enhance error handling and logging for credential lifecycle operations Addresses CodeRabbit feedback on potential race conditions and data integrity issues. --- .../custom_credential_vault_service.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index 8831ae9571..233f796854 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -108,12 +108,30 @@ async def create_credential(self, organization_id: str, data: CreateCredentialRe ) # Create record in Skyvern database - credential = await self._create_db_credential( - organization_id=organization_id, - data=data, - item_id=item_id, - vault_type=CredentialVaultType.CUSTOM, - ) + try: + credential = await self._create_db_credential( + organization_id=organization_id, + data=data, + item_id=item_id, + vault_type=CredentialVaultType.CUSTOM, + ) + except Exception: + # Attempt to clean up the external credential + LOG.warning( + "DB creation failed, attempting to clean up external credential", + organization_id=organization_id, + item_id=item_id, + ) + try: + await client.delete_credential(item_id) + except Exception as cleanup_error: + LOG.error( + "Failed to clean up orphaned external credential", + organization_id=organization_id, + item_id=item_id, + error=str(cleanup_error), + ) + raise LOG.info( "Successfully created credential in custom vault", @@ -150,15 +168,15 @@ async def delete_credential(self, credential: Credential) -> None: ) try: - # Delete from Skyvern database first - await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id) - # Get the API client for this organization client = await self._get_client_for_organization(credential.organization_id) - # Delete from external API + # Delete from external API first await client.delete_credential(credential.item_id) + # Delete from Skyvern database after successful external deletion + await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id) + LOG.info( "Successfully deleted credential from custom vault", organization_id=credential.organization_id, From 44e1375850c41d3f12b0338b4ea1099a6962da7e Mon Sep 17 00:00:00 2001 From: Vikas Date: Sun, 30 Nov 2025 10:17:55 +0530 Subject: [PATCH 03/14] fix: address CodeRabbit nitpick comments for custom credential service Backend improvements: - Add custom exception class and proper exception chaining - Fix import organization and move json import to top level - Improve error handling with LOG.exception and defensive API validation - Change ValueError to TypeError for type checking errors Frontend improvements: - Fix useEffect dependency array and add ESLint disable comment - Add token masking length validation for short tokens - Enhance error handling with specific 404 vs other error cases - Make network error detection more robust across browsers Documentation: - Update Pydantic v1 .dict() method to v2 .model_dump() in examples Addresses all 12 nitpick comments from CodeRabbit review for better code quality, error handling, and user experience. --- .../credentials/custom-credential-service.mdx | 2 +- .../CustomCredentialServiceConfigForm.tsx | 5 +++-- .../hooks/useCustomCredentialServiceConfig.ts | 14 ++++++++++--- skyvern/forge/forge_app.py | 2 +- .../forge/sdk/api/custom_credential_client.py | 18 ++++++++++++---- skyvern/forge/sdk/routes/credentials.py | 10 ++++----- .../custom_credential_vault_service.py | 21 ++++++++++++------- 7 files changed, 49 insertions(+), 23 deletions(-) diff --git a/fern/credentials/custom-credential-service.mdx b/fern/credentials/custom-credential-service.mdx index 73a4beec0d..a1141b79bd 100644 --- a/fern/credentials/custom-credential-service.mdx +++ b/fern/credentials/custom-credential-service.mdx @@ -143,7 +143,7 @@ async def create_credential( _: None = Depends(verify_token) ): credential_id = f"cred_{uuid.uuid4().hex[:12]}" - credentials_store[credential_id] = request.dict() + credentials_store[credential_id] = request.model_dump() return CredentialResponse(id=credential_id) @app.get("/api/v1/credentials/{credential_id}") diff --git a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx index 4174313789..7e42479815 100644 --- a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx +++ b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx @@ -73,7 +73,8 @@ export function CustomCredentialServiceConfigForm() { if (parsedConfig) { form.reset({ config: parsedConfig }); } - }, [parsedConfig, form]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parsedConfig]); return (
@@ -204,7 +205,7 @@ export function CustomCredentialServiceConfigForm() { {parsedConfig && (
Configured API URL: {parsedConfig.api_base_url}
-
Token (masked): {parsedConfig.api_token.slice(0, 8)}...
+
Token (masked): {parsedConfig.api_token.length > 8 ? `${parsedConfig.api_token.slice(0, 8)}...` : '********'}
)}
diff --git a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts index 30b4147a7e..047ed74e14 100644 --- a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts +++ b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts @@ -22,7 +22,15 @@ export function useCustomCredentialServiceConfig() { return await client .get("/credentials/custom_credential/get") .then((response) => response.data.token) - .catch(() => null); + .catch((error) => { + // 404 likely means not configured yet - return null silently + if (error?.response?.status === 404) { + return null; + } + // Log other errors for debugging but still return null + console.warn("Failed to fetch custom credential service config:", error); + return null; + }); }, }); @@ -115,8 +123,8 @@ export function useCustomCredentialServiceConfig() { throw new Error('Connection timeout after 10 seconds'); } - // Network errors, DNS failures, etc. - if (error instanceof TypeError && error.message.includes('fetch')) { + // Network errors typically manifest as TypeError with various messages + if (error instanceof TypeError) { throw new Error('Network error: Cannot reach the API server. Check the URL and network connectivity.'); } diff --git a/skyvern/forge/forge_app.py b/skyvern/forge/forge_app.py index 4b87173ccb..083698b86c 100644 --- a/skyvern/forge/forge_app.py +++ b/skyvern/forge/forge_app.py @@ -11,6 +11,7 @@ from skyvern.forge.agent_functions import AgentFunction from skyvern.forge.forge_openai_client import ForgeAsyncHttpxClientWrapper from skyvern.forge.sdk.api.azure import AzureClientFactory +from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient from skyvern.forge.sdk.api.llm.api_handler_factory import LLMAPIHandlerFactory from skyvern.forge.sdk.api.llm.models import LLMAPIHandler from skyvern.forge.sdk.api.real_azure import RealAzureClientFactory @@ -24,7 +25,6 @@ from skyvern.forge.sdk.experimentation.providers import BaseExperimentationProvider, NoOpExperimentationProvider from skyvern.forge.sdk.schemas.credentials import CredentialVaultType from skyvern.forge.sdk.schemas.organizations import AzureClientSecretCredential, Organization -from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient from skyvern.forge.sdk.services.credential.azure_credential_vault_service import AzureCredentialVaultService from skyvern.forge.sdk.services.credential.bitwarden_credential_service import BitwardenCredentialVaultService from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py index 2fb535e99f..02390da47c 100644 --- a/skyvern/forge/sdk/api/custom_credential_client.py +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -56,13 +56,18 @@ def _credential_to_api_payload(self, credential: PasswordCredential | CreditCard "card_brand": credential.card_brand, } else: - raise ValueError(f"Unsupported credential type: {type(credential)}") + raise TypeError(f"Unsupported credential type: {type(credential)}") def _api_response_to_credential(self, credential_data: Dict[str, Any], name: str, item_id: str) -> CredentialItem: """Convert API response to Skyvern CredentialItem.""" credential_type = credential_data.get("type") if credential_type == "password": + required_fields = ["username", "password"] + missing = [f for f in required_fields if f not in credential_data] + if missing: + raise ValueError(f"Missing required password fields from API: {missing}") + credential = PasswordCredential( username=credential_data["username"], password=credential_data["password"], @@ -76,6 +81,11 @@ def _api_response_to_credential(self, credential_data: Dict[str, Any], name: str credential_type=CredentialType.PASSWORD, ) elif credential_type == "credit_card": + required_fields = ["card_holder_name", "card_number", "card_exp_month", "card_exp_year", "card_cvv", "card_brand"] + missing = [f for f in required_fields if f not in credential_data] + if missing: + raise ValueError(f"Missing required credit card fields from API: {missing}") + credential = CreditCardCredential( card_holder_name=credential_data["card_holder_name"], card_number=credential_data["card_number"], @@ -162,7 +172,7 @@ async def create_credential(self, name: str, credential: PasswordCredential | Cr error=str(e), exc_info=True, ) - raise HttpException(500, f"Failed to create credential via custom API: {str(e)}") + raise HttpException(500, f"Failed to create credential via custom API: {e!s}") from e async def get_credential(self, credential_id: str, name: str) -> CredentialItem: """ @@ -215,7 +225,7 @@ async def get_credential(self, credential_id: str, name: str) -> CredentialItem: error=str(e), exc_info=True, ) - raise HttpException(500, f"Failed to retrieve credential via custom API: {str(e)}") + raise HttpException(500, f"Failed to retrieve credential via custom API: {e!s}") from e async def delete_credential(self, credential_id: str) -> None: """ @@ -259,4 +269,4 @@ async def delete_credential(self, credential_id: str) -> None: error=str(e), exc_info=True, ) - raise HttpException(500, f"Failed to delete credential via custom API: {str(e)}") + raise HttpException(500, f"Failed to delete credential via custom API: {e!s}") from e diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index 3a07020b17..d8d142cf24 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -1,3 +1,4 @@ +import json import structlog from fastapi import BackgroundTasks, Body, Depends, HTTPException, Path, Query @@ -660,8 +661,8 @@ async def get_custom_credential_service_config( ) raise HTTPException( status_code=500, - detail=f"Failed to get custom credential service configuration: {str(e)}", - ) + detail=f"Failed to get custom credential service configuration: {e!s}", + ) from e @base_router.post( @@ -694,7 +695,6 @@ async def update_custom_credential_service_config( ) # Store the configuration as JSON in the token field - import json config_json = json.dumps(request.config.model_dump()) # Create the new configuration @@ -721,8 +721,8 @@ async def update_custom_credential_service_config( ) raise HTTPException( status_code=500, - detail=f"Failed to create or update custom credential service configuration: {str(e)}", - ) + detail=f"Failed to create or update custom credential service configuration: {e!s}", + ) from e async def _get_credential_vault_service() -> CredentialVaultService: diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index 233f796854..47fab3f139 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -1,6 +1,7 @@ import json import structlog +from skyvern.exceptions import SkyvernException from skyvern.forge import app from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType @@ -15,6 +16,11 @@ LOG = structlog.get_logger() +class CustomCredentialConfigurationError(SkyvernException): + """Raised when custom credential service configuration is invalid or missing.""" + pass + + class CustomCredentialVaultService(CredentialVaultService): """Custom credential vault service that uses HTTP API for storing credentials.""" @@ -52,7 +58,9 @@ async def _get_client_for_organization(self, organization_id: str) -> CustomCred ) if not auth_token: - raise Exception(f"Custom credential service not configured for organization {organization_id}") + raise CustomCredentialConfigurationError( + f"Custom credential service not configured for organization {organization_id}" + ) # Parse the stored configuration config_data = json.loads(auth_token.token) @@ -64,18 +72,17 @@ async def _get_client_for_organization(self, organization_id: str) -> CustomCred ) except json.JSONDecodeError as e: - LOG.error( + LOG.exception( "Failed to parse custom credential service configuration", organization_id=organization_id, - error=str(e), ) - raise Exception(f"Invalid custom credential service configuration for organization {organization_id}") + raise CustomCredentialConfigurationError( + f"Invalid custom credential service configuration for organization {organization_id}" + ) from e except Exception as e: - LOG.error( + LOG.exception( "Failed to get custom credential service configuration", organization_id=organization_id, - error=str(e), - exc_info=True, ) raise From 3fa111f25040221066e1e8db483f7bffd89fc765 Mon Sep 17 00:00:00 2001 From: Vikas Date: Sun, 30 Nov 2025 19:35:51 +0530 Subject: [PATCH 04/14] fix: resolve CodeRabbit review feedback for custom credential service Frontend improvements: - Re-add form to useEffect dependency array for better maintainability - Replace deprecated typing.Dict with built-in dict for Python 3.11+ Backend cleanup: - Remove unused exception variable in custom credential vault service - Update type hints to use modern Python syntax Addresses PR review comments and linter warnings for cleaner code. --- .../src/components/CustomCredentialServiceConfigForm.tsx | 3 +-- skyvern/forge/sdk/api/custom_credential_client.py | 8 ++++---- .../credential/custom_credential_vault_service.py | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx index 7e42479815..cf80cd36a5 100644 --- a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx +++ b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx @@ -73,8 +73,7 @@ export function CustomCredentialServiceConfigForm() { if (parsedConfig) { form.reset({ config: parsedConfig }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [parsedConfig]); + }, [parsedConfig, form]); return (
diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py index 02390da47c..a61c56c6b7 100644 --- a/skyvern/forge/sdk/api/custom_credential_client.py +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -1,5 +1,5 @@ import structlog -from typing import Any, Dict +from typing import Any from skyvern.exceptions import HttpException from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_delete, aiohttp_get_json, aiohttp_post @@ -28,14 +28,14 @@ def __init__(self, api_base_url: str, api_token: str): self.api_base_url = api_base_url.rstrip("/") self.api_token = api_token - def _get_auth_headers(self) -> Dict[str, str]: + def _get_auth_headers(self) -> dict[str, str]: """Get headers for API authentication.""" return { "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", } - def _credential_to_api_payload(self, credential: PasswordCredential | CreditCardCredential) -> Dict[str, Any]: + def _credential_to_api_payload(self, credential: PasswordCredential | CreditCardCredential) -> dict[str, Any]: """Convert Skyvern credential to API payload format.""" if isinstance(credential, PasswordCredential): return { @@ -58,7 +58,7 @@ def _credential_to_api_payload(self, credential: PasswordCredential | CreditCard else: raise TypeError(f"Unsupported credential type: {type(credential)}") - def _api_response_to_credential(self, credential_data: Dict[str, Any], name: str, item_id: str) -> CredentialItem: + def _api_response_to_credential(self, credential_data: dict[str, Any], name: str, item_id: str) -> CredentialItem: """Convert API response to Skyvern CredentialItem.""" credential_type = credential_data.get("type") diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index 47fab3f139..3d133b65c7 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -79,7 +79,7 @@ async def _get_client_for_organization(self, organization_id: str) -> CustomCred raise CustomCredentialConfigurationError( f"Invalid custom credential service configuration for organization {organization_id}" ) from e - except Exception as e: + except Exception: LOG.exception( "Failed to get custom credential service configuration", organization_id=organization_id, From b618844d56b69770f3e9a8fd4e651e2057c43e5f Mon Sep 17 00:00:00 2001 From: Vikas Date: Mon, 1 Dec 2025 20:04:03 +0530 Subject: [PATCH 05/14] Only include form.reset in useEffect dependency --- .../src/components/CustomCredentialServiceConfigForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx index cf80cd36a5..b919b8a5de 100644 --- a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx +++ b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx @@ -73,7 +73,7 @@ export function CustomCredentialServiceConfigForm() { if (parsedConfig) { form.reset({ config: parsedConfig }); } - }, [parsedConfig, form]); + }, [parsedConfig, form.reset]); return (
From deea45633ee23dc6f9289bdd481c50e53d7ede75 Mon Sep 17 00:00:00 2001 From: Vikas Date: Mon, 1 Dec 2025 20:09:12 +0530 Subject: [PATCH 06/14] Remove test connection button from custom credential config form --- .../CustomCredentialServiceConfigForm.tsx | 16 ---- .../hooks/useCustomCredentialServiceConfig.ts | 74 ------------------- 2 files changed, 90 deletions(-) diff --git a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx index b919b8a5de..fd976b4160 100644 --- a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx +++ b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx @@ -42,8 +42,6 @@ export function CustomCredentialServiceConfigForm() { isLoading, createOrUpdateConfig, isUpdating, - testConnection, - isTesting, } = useCustomCredentialServiceConfig(); const form = useForm({ @@ -60,11 +58,6 @@ export function CustomCredentialServiceConfigForm() { createOrUpdateConfig(data); }; - const handleTestConnection = () => { - const formData = form.getValues(); - testConnection(formData.config); - }; - const toggleApiTokenVisibility = () => { setShowApiToken((v) => !v); }; @@ -168,15 +161,6 @@ export function CustomCredentialServiceConfigForm() { {isUpdating ? "Updating..." : "Update Configuration"} - - {customCredentialServiceAuthToken && (
Last updated:{" "} diff --git a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts index 047ed74e14..8c6aed5375 100644 --- a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts +++ b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts @@ -77,78 +77,6 @@ export function useCustomCredentialServiceConfig() { }, }); - const testConnectionMutation = useMutation({ - mutationFn: async (config: CustomCredentialServiceConfig) => { - // Test the connection by making a request to the base API URL - const testUrl = config.api_base_url.replace(/\/$/, ''); - - try { - // Create an AbortController for timeout handling - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const response = await fetch(testUrl, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${config.api_token}`, - 'Content-Type': 'application/json', - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - // Consider connection successful if we can reach the API and get any response - // (even 404, 405, etc. - these indicate the server is reachable) - if (response.status >= 200 && response.status < 500) { - if (response.status === 401 || response.status === 403) { - return { - success: true, - message: "Connection successful (authentication may need verification)" - }; - } - if (response.status === 404 || response.status === 405) { - return { - success: true, - message: "Connection successful (API endpoint reachable)" - }; - } - return { success: true, message: "Connection successful" }; - } - - // Only treat 5xx errors as connection failures - throw new Error(`Server error: HTTP ${response.status}`); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('Connection timeout after 10 seconds'); - } - - // Network errors typically manifest as TypeError with various messages - if (error instanceof TypeError) { - throw new Error('Network error: Cannot reach the API server. Check the URL and network connectivity.'); - } - - throw new Error( - `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - }, - onSuccess: (data) => { - toast({ - title: "Connection Test Successful", - description: data.message, - }); - }, - onError: (error: unknown) => { - const message = - (error as Error)?.message || "Connection test failed"; - toast({ - title: "Connection Test Failed", - description: message, - variant: "destructive", - }); - }, - }); return { customCredentialServiceAuthToken, @@ -156,7 +84,5 @@ export function useCustomCredentialServiceConfig() { isLoading, createOrUpdateConfig: createOrUpdateConfigMutation.mutate, isUpdating: createOrUpdateConfigMutation.isPending, - testConnection: testConnectionMutation.mutate, - isTesting: testConnectionMutation.isPending, }; } From c58a8c2ab586607cca75da7c1f06fb644e390028 Mon Sep 17 00:00:00 2001 From: Vikas Date: Mon, 1 Dec 2025 20:15:52 +0530 Subject: [PATCH 07/14] Use useMemo for custom credential token input handling --- .../src/hooks/useCustomCredentialServiceConfig.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts index 8c6aed5375..48e7deab23 100644 --- a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts +++ b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; import { getClient } from "@/api/AxiosClient"; import { useCredentialGetter } from "./useCredentialGetter"; import { @@ -35,7 +36,7 @@ export function useCustomCredentialServiceConfig() { }); // Parse the configuration from the stored token - const parsedConfig: CustomCredentialServiceConfig | null = (() => { + const parsedConfig: CustomCredentialServiceConfig | null = useMemo(() => { if (!customCredentialServiceAuthToken?.token) return null; try { @@ -43,7 +44,7 @@ export function useCustomCredentialServiceConfig() { } catch { return null; } - })(); + }, [customCredentialServiceAuthToken?.token]); const createOrUpdateConfigMutation = useMutation({ mutationFn: async (data: CreateCustomCredentialServiceConfigRequest) => { From 0cbe3f14bcbc3203b0dcc020fc75f42e6ca01622 Mon Sep 17 00:00:00 2001 From: Vikas Date: Tue, 2 Dec 2025 18:02:15 +0530 Subject: [PATCH 08/14] Remove unsused post_delete_credential_item method in custom credential vault --- .../credential/custom_credential_vault_service.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index 3d133b65c7..203afd35e8 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -202,21 +202,6 @@ async def delete_credential(self, credential: Credential) -> None: ) raise - async def post_delete_credential_item(self, item_id: str) -> None: - """ - Optional hook for scheduling background cleanup tasks after credential deletion. - For the custom vault service, we don't need additional cleanup since deletion - is synchronous via the API. - - Args: - item_id: ID of the credential item that was deleted - """ - LOG.info( - "Post-delete hook called for custom credential vault", - item_id=item_id, - ) - # No additional cleanup needed for custom vault service - async def get_credential_item(self, db_credential: Credential) -> CredentialItem: """ Retrieve the full credential data from the custom vault. From 2876bd4bf908e1ae61df770eb6518f6e1919a05d Mon Sep 17 00:00:00 2001 From: Vikas Date: Tue, 2 Dec 2025 18:08:55 +0530 Subject: [PATCH 09/14] Fix import related linting errors --- skyvern/forge/sdk/api/custom_credential_client.py | 8 ++++---- skyvern/forge/sdk/routes/credentials.py | 3 ++- .../credential/custom_credential_vault_service.py | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py index a61c56c6b7..09a2c044a2 100644 --- a/skyvern/forge/sdk/api/custom_credential_client.py +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -1,14 +1,14 @@ -import structlog from typing import Any +import structlog + from skyvern.exceptions import HttpException from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_delete, aiohttp_get_json, aiohttp_post from skyvern.forge.sdk.schemas.credentials import ( - CreateCredentialRequest, - CreditCardCredential, - PasswordCredential, CredentialItem, CredentialType, + CreditCardCredential, + PasswordCredential, ) LOG = structlog.get_logger() diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index d8d142cf24..cb2b9d9a1c 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -1,4 +1,5 @@ import json + import structlog from fastapi import BackgroundTasks, Body, Depends, HTTPException, Path, Query @@ -32,10 +33,10 @@ from skyvern.forge.sdk.schemas.organizations import ( AzureClientSecretCredentialResponse, CreateAzureClientSecretCredentialRequest, + CreateCustomCredentialServiceConfigRequest, CreateOnePasswordTokenRequest, CreateOnePasswordTokenResponse, CustomCredentialServiceConfigResponse, - CreateCustomCredentialServiceConfigRequest, Organization, ) from skyvern.forge.sdk.schemas.totp_codes import OTPType, TOTPCode, TOTPCodeCreate diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index 203afd35e8..f9f500b7e7 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -1,4 +1,5 @@ import json + import structlog from skyvern.exceptions import SkyvernException From 5f91f3d98d6f02cb951fc1691f1280e02174b928 Mon Sep 17 00:00:00 2001 From: Vikas Date: Tue, 2 Dec 2025 18:15:47 +0530 Subject: [PATCH 10/14] Fix autoflake linting errors --- .../sdk/services/credential/custom_credential_vault_service.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index f9f500b7e7..36cbd615ff 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -19,8 +19,6 @@ class CustomCredentialConfigurationError(SkyvernException): """Raised when custom credential service configuration is invalid or missing.""" - pass - class CustomCredentialVaultService(CredentialVaultService): """Custom credential vault service that uses HTTP API for storing credentials.""" From 87d11d016adf24f036dc63094b39715fb72e82f4 Mon Sep 17 00:00:00 2001 From: Vikas Date: Tue, 2 Dec 2025 18:24:28 +0530 Subject: [PATCH 11/14] Fix mypy linting errors --- skyvern/forge/sdk/db/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index 4f023738a0..71910a606d 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -910,7 +910,7 @@ async def update_organization( async def get_valid_org_auth_token( self, organization_id: str, - token_type: Literal["api", "onepassword_service_account"], + token_type: Literal["api", "onepassword_service_account", "custom_credential_service"], ) -> OrganizationAuthToken | None: ... @overload @@ -923,7 +923,7 @@ async def get_valid_org_auth_token( # type: ignore async def get_valid_org_auth_token( self, organization_id: str, - token_type: Literal["api", "onepassword_service_account", "azure_client_secret_credential"], + token_type: Literal["api", "onepassword_service_account", "azure_client_secret_credential", "custom_credential_service"], ) -> OrganizationAuthToken | AzureOrganizationAuthToken | None: try: async with self.Session() as session: From 0c92199fed4a00db1bd54f223845f11b8f776b4c Mon Sep 17 00:00:00 2001 From: Vikas Date: Tue, 2 Dec 2025 18:36:53 +0530 Subject: [PATCH 12/14] Fix ESLinit linting errors --- .../CustomCredentialServiceConfigForm.tsx | 32 +++++++++++++------ .../hooks/useCustomCredentialServiceConfig.ts | 9 ++++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx index fd976b4160..b81de08c7f 100644 --- a/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx +++ b/skyvern-frontend/src/components/CustomCredentialServiceConfigForm.tsx @@ -66,17 +66,17 @@ export function CustomCredentialServiceConfigForm() { if (parsedConfig) { form.reset({ config: parsedConfig }); } - }, [parsedConfig, form.reset]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parsedConfig]); return (
-

- Custom Credential Service -

+

Custom Credential Service

- Configure your custom HTTP API for credential management. Your API should support the standard CRUD operations. + Configure your custom HTTP API for credential management. Your API + should support the standard CRUD operations.

{customCredentialServiceAuthToken && ( @@ -100,7 +100,8 @@ export function CustomCredentialServiceConfigForm() { API Base URL - The base URL of your custom credential service API (e.g., https://credentials.company.com/api/v1) + The base URL of your custom credential service API (e.g., + https://credentials.company.com/api/v1)
@@ -125,7 +126,8 @@ export function CustomCredentialServiceConfigForm() { API Token - Bearer token for authenticating with your custom credential service + Bearer token for authenticating with your custom credential + service
@@ -175,7 +177,9 @@ export function CustomCredentialServiceConfigForm() { {customCredentialServiceAuthToken && (
-

Configuration Information

+

+ Configuration Information +

ID: {customCredentialServiceAuthToken.id}
Type: {customCredentialServiceAuthToken.token_type}
@@ -187,8 +191,16 @@ export function CustomCredentialServiceConfigForm() {
{parsedConfig && (
-
Configured API URL: {parsedConfig.api_base_url}
-
Token (masked): {parsedConfig.api_token.length > 8 ? `${parsedConfig.api_token.slice(0, 8)}...` : '********'}
+
+ Configured API URL:{" "} + {parsedConfig.api_base_url} +
+
+ Token (masked):{" "} + {parsedConfig.api_token.length > 8 + ? `${parsedConfig.api_token.slice(0, 8)}...` + : "********"} +
)}
diff --git a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts index 48e7deab23..c62f8553ab 100644 --- a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts +++ b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts @@ -29,7 +29,10 @@ export function useCustomCredentialServiceConfig() { return null; } // Log other errors for debugging but still return null - console.warn("Failed to fetch custom credential service config:", error); + console.warn( + "Failed to fetch custom credential service config:", + error, + ); return null; }); }, @@ -61,7 +64,8 @@ export function useCustomCredentialServiceConfig() { }); toast({ title: "Success", - description: "Custom credential service configuration updated successfully", + description: + "Custom credential service configuration updated successfully", }); }, onError: (error: unknown) => { @@ -78,7 +82,6 @@ export function useCustomCredentialServiceConfig() { }, }); - return { customCredentialServiceAuthToken, parsedConfig, From da0826d65d180a64947ed31703c589e955bcbd8f Mon Sep 17 00:00:00 2001 From: Vikas Date: Tue, 2 Dec 2025 19:19:40 +0530 Subject: [PATCH 13/14] Fix incorrect usage of HttpException in custom credential client --- skyvern/forge/sdk/api/custom_credential_client.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py index 09a2c044a2..b8a6506889 100644 --- a/skyvern/forge/sdk/api/custom_credential_client.py +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -141,7 +141,7 @@ async def create_credential(self, name: str, credential: PasswordCredential | Cr ) if not response: - raise HttpException(500, f"Empty response from custom credential API: {url}") + raise HttpException(500, url, "Empty response from custom credential API") # Extract credential ID from response credential_id = response.get("id") @@ -151,7 +151,7 @@ async def create_credential(self, name: str, credential: PasswordCredential | Cr url=url, response=response, ) - raise HttpException(500, "Invalid response format from custom credential API") + raise HttpException(500, url, "Invalid response format from custom credential API") LOG.info( "Successfully created credential via custom API", @@ -172,7 +172,7 @@ async def create_credential(self, name: str, credential: PasswordCredential | Cr error=str(e), exc_info=True, ) - raise HttpException(500, f"Failed to create credential via custom API: {e!s}") from e + raise HttpException(500, url, f"Failed to create credential via custom API: {e!s}") from e async def get_credential(self, credential_id: str, name: str) -> CredentialItem: """ @@ -205,7 +205,7 @@ async def get_credential(self, credential_id: str, name: str) -> CredentialItem: ) if not response: - raise HttpException(404, f"Credential not found in custom API: {credential_id}") + raise HttpException(404, url, f"Credential not found: {credential_id}") LOG.info( "Successfully retrieved credential via custom API", @@ -225,7 +225,7 @@ async def get_credential(self, credential_id: str, name: str) -> CredentialItem: error=str(e), exc_info=True, ) - raise HttpException(500, f"Failed to retrieve credential via custom API: {e!s}") from e + raise HttpException(500, url, f"Failed to retrieve credential via custom API: {e!s}") from e async def delete_credential(self, credential_id: str) -> None: """ @@ -269,4 +269,4 @@ async def delete_credential(self, credential_id: str) -> None: error=str(e), exc_info=True, ) - raise HttpException(500, f"Failed to delete credential via custom API: {e!s}") from e + raise HttpException(500, url, f"Failed to delete credential via custom API: {e!s}") from e From 27521e450c40c706bc86fa2e2f54e912d6b5d6d9 Mon Sep 17 00:00:00 2001 From: stas Date: Tue, 2 Dec 2025 18:05:12 -0700 Subject: [PATCH 14/14] Run lint --- skyvern/forge/sdk/api/custom_credential_client.py | 9 ++++++++- skyvern/forge/sdk/db/client.py | 4 +++- .../credential/custom_credential_vault_service.py | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py index b8a6506889..9bab335424 100644 --- a/skyvern/forge/sdk/api/custom_credential_client.py +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -81,7 +81,14 @@ def _api_response_to_credential(self, credential_data: dict[str, Any], name: str credential_type=CredentialType.PASSWORD, ) elif credential_type == "credit_card": - required_fields = ["card_holder_name", "card_number", "card_exp_month", "card_exp_year", "card_cvv", "card_brand"] + required_fields = [ + "card_holder_name", + "card_number", + "card_exp_month", + "card_exp_year", + "card_cvv", + "card_brand", + ] missing = [f for f in required_fields if f not in credential_data] if missing: raise ValueError(f"Missing required credit card fields from API: {missing}") diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index 71910a606d..05e54c4980 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -923,7 +923,9 @@ async def get_valid_org_auth_token( # type: ignore async def get_valid_org_auth_token( self, organization_id: str, - token_type: Literal["api", "onepassword_service_account", "azure_client_secret_credential", "custom_credential_service"], + token_type: Literal[ + "api", "onepassword_service_account", "azure_client_secret_credential", "custom_credential_service" + ], ) -> OrganizationAuthToken | AzureOrganizationAuthToken | None: try: async with self.Session() as session: diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py index 36cbd615ff..581629becf 100644 --- a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -20,6 +20,7 @@ class CustomCredentialConfigurationError(SkyvernException): """Raised when custom credential service configuration is invalid or missing.""" + class CustomCredentialVaultService(CredentialVaultService): """Custom credential vault service that uses HTTP API for storing credentials."""