From 720d47d754c5fe10fc926db518e73871e291fbfc Mon Sep 17 00:00:00 2001 From: Suchintan Date: Thu, 2 Jul 2026 17:24:17 -0400 Subject: [PATCH] SKY-11678 Add local credential vault persistence to OSS Docker (#7003) Co-authored-by: Codex --- .dockerignore | 3 + .env.example | 17 ++ .gitignore | 1 + Dockerfile | 12 +- docker-compose.yml | 8 + docs/api-reference/openapi.json | 7 +- .../external-providers.mdx | 2 +- .../credentials/get-credentials.mdx | 2 +- fern/openapi/skyvern_openapi.json | 7 +- .../client/requests/GetCredentialsRequest.ts | 2 +- .../src/api/types/CredentialResponse.ts | 2 +- .../src/api/types/CredentialVaultType.ts | 2 + skyvern/client/client.py | 4 +- skyvern/client/raw_client.py | 4 +- skyvern/client/types/credential_response.py | 2 +- skyvern/client/types/credential_vault_type.py | 2 +- skyvern/config.py | 10 +- skyvern/forge/forge_app.py | 6 + skyvern/forge/sdk/routes/credentials.py | 67 +++-- skyvern/forge/sdk/schemas/credentials.py | 7 +- .../skyvern_credential_vault_service.py | 208 +++++++++++++++ .../test_credential_vault_type_override.py | 182 ++++++++++++- .../test_skyvern_credential_vault_service.py | 242 ++++++++++++++++++ 23 files changed, 753 insertions(+), 46 deletions(-) create mode 100644 skyvern/forge/sdk/services/credential/skyvern_credential_vault_service.py create mode 100644 tests/unit/test_skyvern_credential_vault_service.py diff --git a/.dockerignore b/.dockerignore index 8fc469dd1..bc05d817a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,9 @@ **/traces **/inputs **/har +**/downloads +**/browser_sessions +**/credential_vault **/.git **/.github **/.mypy_cache diff --git a/.env.example b/.env.example index ec2aa6a95..8de4fcc0b 100644 --- a/.env.example +++ b/.env.example @@ -139,12 +139,29 @@ ANALYTICS_ID="anonymous" # OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration OP_SERVICE_ACCOUNT_TOKEN="" +# ============================================================================= +# SKYVERN CREDENTIAL VAULT CONFIGURATION +# ============================================================================= +# Built-in encrypted local vault used by default by the OSS Docker Compose setup. +# Docker also sets ENABLE_LOCAL_CREDENTIAL_VAULT=true and persists this under /data/credential_vault. +CREDENTIAL_VAULT_TYPE=skyvern +ENABLE_LOCAL_CREDENTIAL_VAULT=true +# LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault +# Production self-hosters should set LOCAL_CREDENTIAL_VAULT_KEY from an external secret store. +# If unset, Skyvern generates .fernet_key inside LOCAL_CREDENTIAL_VAULT_PATH; backups or host-dir +# exposure of that directory include both encrypted items and the key needed to decrypt them. +# LOCAL_CREDENTIAL_VAULT_KEY= +# The OSS Docker volumes for browser_sessions/ and downloads/ can contain plaintext cookies, +# session tokens, and downloaded files. Treat them as sensitive and exclude them from casual backups. + # Enable recording skyvern logs as artifacts ENABLE_LOG_ARTIFACTS=false # ============================================================================= # SKYVERN BITWARDEN CONFIGURATION # ============================================================================= +# Set CREDENTIAL_VAULT_TYPE=bitwarden to use Bitwarden or vaultwarden instead +# of the built-in Skyvern credential vault. # Your organization ID in official Bitwarden server or vaultwarden (if using organizations) SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID=your-org-id-here diff --git a/.gitignore b/.gitignore index b3f08ff9e..3592cdf38 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ ig_* # Skyvern ignores browser_sessions/ +credential_vault/ videos/ skyvern/artifacts/ artifacts/ diff --git a/Dockerfile b/Dockerfile index 351c3d5a5..d954a1565 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN uv pip compile pyproject.toml --extra server --python-version 3.11 -o requir FROM python:3.11-slim-bookworm WORKDIR /app COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt +COPY ./skyvern/forge/sdk/utils/tesseract_languages.py /tmp/tesseract_languages.py RUN pip install --upgrade pip setuptools wheel # --no-deps: requirements.txt is fully resolved by uv, including the # pyproject overrides that loosen litellm's jsonschema==4.23.0 pin. @@ -18,7 +19,13 @@ RUN pip install --upgrade pip setuptools wheel RUN pip install --no-cache-dir --no-deps -r requirements.txt RUN playwright install-deps RUN playwright install -RUN apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc && apt-get clean +RUN apt-get update && \ + apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc tesseract-ocr $(python /tmp/tesseract_languages.py --apt-packages) && \ + tesseract --version && \ + rm /tmp/tesseract_languages.py && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + RUN pip install --no-cache-dir websockify COPY .nvmrc /app/.nvmrc @@ -47,6 +54,9 @@ ENV VIDEO_PATH=/data/videos ENV HAR_PATH=/data/har ENV LOG_PATH=/data/log ENV ARTIFACT_STORAGE_PATH=/data/artifacts +ENV DOWNLOAD_PATH=/data/downloads +ENV BROWSER_SESSION_BASE_PATH=/data/browser_sessions +ENV LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault # cache tiktoken RUN python /app/scripts/load_tiktoken.py diff --git a/docker-compose.yml b/docker-compose.yml index 5614266da..fb8999bdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,9 @@ services: - ./videos:/data/videos - ./har:/data/har - ./log:/data/log + - ./downloads:/data/downloads + - ./browser_sessions:/data/browser_sessions + - ./credential_vault:/data/credential_vault # Generated credentials allow the UI to pick up the local API key on first startup. - ./.skyvern:/app/.skyvern # Uncomment the following two lines if you want to connect to any local changes @@ -62,6 +65,11 @@ services: - BROWSER_REMOTE_DEBUGGING_URL=${BROWSER_REMOTE_DEBUGGING_URL:-http://127.0.0.1:9222} - BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-} - BROWSER_CDP_CONNECT_TIMEOUT_MS=${BROWSER_CDP_CONNECT_TIMEOUT_MS:-120000} + - DOWNLOAD_PATH=/data/downloads + - BROWSER_SESSION_BASE_PATH=/data/browser_sessions + - CREDENTIAL_VAULT_TYPE=${CREDENTIAL_VAULT_TYPE:-skyvern} + - ENABLE_LOCAL_CREDENTIAL_VAULT=${ENABLE_LOCAL_CREDENTIAL_VAULT:-true} + - LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault - ENABLE_CODE_BLOCK=true # --- Control your own browser (Chrome/Chromium) --- # Prefer Chrome's chrome://inspect/#remote-debugging flow for an existing profile. diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 16a7b5f04..adc135536 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -3645,10 +3645,10 @@ "type": "null" } ], - "description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')", + "description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')", "title": "Vault Type" }, - "description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')" + "description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')" }, { "name": "credential_type", @@ -10550,7 +10550,7 @@ "type": "null" } ], - "description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')" + "description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')" }, "browser_profile_id": { "anyOf": [ @@ -10670,6 +10670,7 @@ "CredentialVaultType": { "type": "string", "enum": [ + "skyvern", "bitwarden", "azure_vault", "gcp", diff --git a/docs/cloud/managing-credentials/external-providers.mdx b/docs/cloud/managing-credentials/external-providers.mdx index d568ef68f..c9b60dfd7 100644 --- a/docs/cloud/managing-credentials/external-providers.mdx +++ b/docs/cloud/managing-credentials/external-providers.mdx @@ -363,7 +363,7 @@ Restart Skyvern after setting these variables. | Status stays Inactive | Verify the API base URL is a valid URL and the API token is not empty. The configuration is validated on save but does not make a live request to your server. | | Credentials not created | Review your API logs for auth errors. Ensure the response includes an `id` field. Skyvern expects HTTP 200 for all operations. | | Credentials not retrieved | Ensure the GET response includes all required fields for the credential type (`username` and `password` for passwords, all card fields for credit cards, `secret_value` for secrets). | -| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. The default vault type is `bitwarden`, so this variable must be explicitly set. | +| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. Deployment defaults vary, so `custom` must be explicitly set for an external service. | --- diff --git a/docs/sdk-reference/credentials/get-credentials.mdx b/docs/sdk-reference/credentials/get-credentials.mdx index 6b1af0f8f..0121dfe01 100644 --- a/docs/sdk-reference/credentials/get-credentials.mdx +++ b/docs/sdk-reference/credentials/get-credentials.mdx @@ -26,7 +26,7 @@ for (const c of creds) { |-----------|------|----------|---------|-------------| | `page` | `int` | No | `None` | Page number. | | `page_size` | `int` | No | `None` | Results per page. | -| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"custom"`, `"bitwarden"`, `"azure_vault"`). | +| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"skyvern"`, `"custom"`, `"bitwarden"`, `"azure_vault"`). | | `request_options` | `RequestOptions` | No | `None` | Per-request configuration (see below). | ### Returns `list[CredentialResponse]` diff --git a/fern/openapi/skyvern_openapi.json b/fern/openapi/skyvern_openapi.json index 39d44a0eb..ef4bbb20c 100644 --- a/fern/openapi/skyvern_openapi.json +++ b/fern/openapi/skyvern_openapi.json @@ -3343,10 +3343,10 @@ "type": "null" } ], - "description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')", + "description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')", "title": "Vault Type" }, - "description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')" + "description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')" }, { "name": "credential_type", @@ -9678,7 +9678,7 @@ "type": "null" } ], - "description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')" + "description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')" }, "browser_profile_id": { "anyOf": [ @@ -9767,6 +9767,7 @@ "CredentialVaultType": { "type": "string", "enum": [ + "skyvern", "bitwarden", "azure_vault", "gcp", diff --git a/skyvern-ts/client/src/api/client/requests/GetCredentialsRequest.ts b/skyvern-ts/client/src/api/client/requests/GetCredentialsRequest.ts index 284f0a78f..123c0050e 100644 --- a/skyvern-ts/client/src/api/client/requests/GetCredentialsRequest.ts +++ b/skyvern-ts/client/src/api/client/requests/GetCredentialsRequest.ts @@ -15,6 +15,6 @@ export interface GetCredentialsRequest { page?: number; /** Number of items per page */ page_size?: number; - /** Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault') */ + /** Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault') */ vault_type?: Skyvern.CredentialVaultType; } diff --git a/skyvern-ts/client/src/api/types/CredentialResponse.ts b/skyvern-ts/client/src/api/types/CredentialResponse.ts index d5d7d9a34..d1b566b97 100644 --- a/skyvern-ts/client/src/api/types/CredentialResponse.ts +++ b/skyvern-ts/client/src/api/types/CredentialResponse.ts @@ -14,7 +14,7 @@ export interface CredentialResponse { credential_type: Skyvern.CredentialTypeOutput; /** Name of the credential */ name: string; - /** Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom') */ + /** Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom') */ vault_type?: Skyvern.CredentialVaultType; /** Browser profile ID linked to this credential */ browser_profile_id?: string; diff --git a/skyvern-ts/client/src/api/types/CredentialVaultType.ts b/skyvern-ts/client/src/api/types/CredentialVaultType.ts index d5e15f53f..3c87dd7b9 100644 --- a/skyvern-ts/client/src/api/types/CredentialVaultType.ts +++ b/skyvern-ts/client/src/api/types/CredentialVaultType.ts @@ -1,8 +1,10 @@ // This file was auto-generated by Fern from our API Definition. export const CredentialVaultType = { + Skyvern: "skyvern", Bitwarden: "bitwarden", AzureVault: "azure_vault", + Gcp: "gcp", Custom: "custom", } as const; export type CredentialVaultType = (typeof CredentialVaultType)[keyof typeof CredentialVaultType]; diff --git a/skyvern/client/client.py b/skyvern/client/client.py index 0a6c887d8..814047c73 100644 --- a/skyvern/client/client.py +++ b/skyvern/client/client.py @@ -2079,7 +2079,7 @@ class Skyvern: Number of items per page vault_type : typing.Optional[CredentialVaultType] - Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault') + Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault') request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -5138,7 +5138,7 @@ class AsyncSkyvern: Number of items per page vault_type : typing.Optional[CredentialVaultType] - Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault') + Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault') request_options : typing.Optional[RequestOptions] Request-specific configuration. diff --git a/skyvern/client/raw_client.py b/skyvern/client/raw_client.py index a60a95ed6..d3693789d 100644 --- a/skyvern/client/raw_client.py +++ b/skyvern/client/raw_client.py @@ -2952,7 +2952,7 @@ class RawSkyvern: Number of items per page vault_type : typing.Optional[CredentialVaultType] - Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault') + Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault') request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -6804,7 +6804,7 @@ class AsyncRawSkyvern: Number of items per page vault_type : typing.Optional[CredentialVaultType] - Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault') + Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault') request_options : typing.Optional[RequestOptions] Request-specific configuration. diff --git a/skyvern/client/types/credential_response.py b/skyvern/client/types/credential_response.py index 16b357b46..1294c7663 100644 --- a/skyvern/client/types/credential_response.py +++ b/skyvern/client/types/credential_response.py @@ -37,7 +37,7 @@ class CredentialResponse(UniversalBaseModel): vault_type: typing.Optional[CredentialVaultType] = pydantic.Field(default=None) """ - Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom') + Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom') """ browser_profile_id: typing.Optional[str] = pydantic.Field(default=None) diff --git a/skyvern/client/types/credential_vault_type.py b/skyvern/client/types/credential_vault_type.py index 5b578fc79..d310a1cac 100644 --- a/skyvern/client/types/credential_vault_type.py +++ b/skyvern/client/types/credential_vault_type.py @@ -2,4 +2,4 @@ import typing -CredentialVaultType = typing.Union[typing.Literal["bitwarden", "azure_vault", "custom"], typing.Any] +CredentialVaultType = typing.Union[typing.Literal["skyvern", "bitwarden", "azure_vault", "gcp", "custom"], typing.Any] diff --git a/skyvern/config.py b/skyvern/config.py index 7b6148697..4c00e72e5 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -562,8 +562,11 @@ class Settings(BaseSettings): BITWARDEN_EMAIL: str | None = None OP_SERVICE_ACCOUNT_TOKEN: str | None = None - # Where credentials are stored: bitwarden, azure_vault, gcp, or custom + # Where credentials are stored: skyvern, bitwarden, azure_vault, gcp, or custom CREDENTIAL_VAULT_TYPE: str = "bitwarden" + ENABLE_LOCAL_CREDENTIAL_VAULT: bool | None = None + LOCAL_CREDENTIAL_VAULT_PATH: str = str(Path.home() / ".skyvern" / "credential_vault") + LOCAL_CREDENTIAL_VAULT_KEY: str | None = None # GCP Secret Manager credential vault settings GCP_CREDENTIAL_VAULT_PROJECT_ID: str | None = None # project hosting the Secret Manager secrets @@ -888,6 +891,11 @@ class Settings(BaseSettings): """ return self.ENV != "local" + def is_local_credential_vault_enabled(self) -> bool: + if self.ENABLE_LOCAL_CREDENTIAL_VAULT is not None: + return self.ENABLE_LOCAL_CREDENTIAL_VAULT + return not self.is_cloud_environment() + def execute_all_steps(self) -> bool: """ This provides the functionality to execute steps one by one through the local UI. diff --git a/skyvern/forge/forge_app.py b/skyvern/forge/forge_app.py index 4a999d95e..dd90bf02f 100644 --- a/skyvern/forge/forge_app.py +++ b/skyvern/forge/forge_app.py @@ -41,6 +41,7 @@ from skyvern.forge.sdk.services.credential.bitwarden_credential_service import B 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.services.credential.gcp_credential_vault_service import GcpCredentialVaultService +from skyvern.forge.sdk.services.credential.skyvern_credential_vault_service import SkyvernCredentialVaultService 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 @@ -95,6 +96,7 @@ class ForgeApp: AGENT_FUNCTION: AgentFunction PERSISTENT_SESSIONS_MANAGER: PersistentSessionsManager BROWSER_SESSION_RECORDING_SERVICE: BrowserSessionRecordingService + SKYVERN_CREDENTIAL_VAULT_SERVICE: SkyvernCredentialVaultService | None BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None GCP_CREDENTIAL_VAULT_SERVICE: GcpCredentialVaultService | None @@ -283,6 +285,9 @@ def create_forge_app() -> ForgeApp: app.AZURE_CLIENT_FACTORY = RealAzureClientFactory() app.GCP_CLIENT_FACTORY = RealGcpClientFactory() + app.SKYVERN_CREDENTIAL_VAULT_SERVICE = ( + SkyvernCredentialVaultService() if settings.is_local_credential_vault_enabled() else None + ) app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = BitwardenCredentialVaultService() # Azure Credential Vault Service @@ -329,6 +334,7 @@ def create_forge_app() -> ForgeApp: else CustomCredentialVaultService() # Create service without client for organization-based configuration ) app.CREDENTIAL_VAULT_SERVICES = { + CredentialVaultType.SKYVERN: app.SKYVERN_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.GCP: app.GCP_CREDENTIAL_VAULT_SERVICE, diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index 5f4a35bea..a9bddcc22 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -934,9 +934,10 @@ async def test_login( organization_id=organization_id, ) try: - await app.DATABASE.credentials.delete_credential( + await _delete_temporary_test_login_credential( credential_id=credential_id, organization_id=organization_id, + reason="workflow setup error", ) except Exception: LOG.warning( @@ -1374,20 +1375,11 @@ async def cancel_credential_test( # Only clean up temporary credentials after successful cancellation. # The background task may also try to delete — that's fine, it handles NotFound gracefully. try: - credential = await app.DATABASE.credentials.get_credential( + await _delete_temporary_test_login_credential( credential_id=credential_id, organization_id=organization_id, + reason="test cancellation", ) - if credential and credential.name.startswith("_test_login_"): - await app.DATABASE.credentials.delete_credential( - credential_id=credential_id, - organization_id=organization_id, - ) - LOG.info( - "Cleaned up temporary credential after test cancellation", - credential_id=credential_id, - organization_id=organization_id, - ) except Exception: LOG.warning( "Failed to clean up temporary credential after test cancellation", @@ -1441,14 +1433,10 @@ async def _create_browser_profile_after_workflow( # Clean up temporary credentials created by test-login if credential_name.startswith("_test_login_"): try: - await app.DATABASE.credentials.delete_credential( - credential_id=credential_id, - organization_id=organization_id, - ) - LOG.info( - "Deleted temporary credential after failed test", + await _delete_temporary_test_login_credential( credential_id=credential_id, organization_id=organization_id, + reason="failed test", ) except Exception: LOG.warning( @@ -1601,9 +1589,10 @@ async def _create_browser_profile_after_workflow( # Clean up temporary credentials on poll timeout if credential_name.startswith("_test_login_"): try: - await app.DATABASE.credentials.delete_credential( + await _delete_temporary_test_login_credential( credential_id=credential_id, organization_id=organization_id, + reason="poll timeout", ) except Exception: LOG.warning( @@ -1620,9 +1609,10 @@ async def _create_browser_profile_after_workflow( # Clean up temporary credentials on unexpected error if credential_name.startswith("_test_login_"): try: - await app.DATABASE.credentials.delete_credential( + await _delete_temporary_test_login_credential( credential_id=credential_id, organization_id=organization_id, + reason="profile persistence error", ) except Exception: LOG.warning( @@ -1838,7 +1828,8 @@ async def get_credential_totp_code( if cached_response is not None: return cached_response - credential_service = await _get_credential_vault_service(vault_type_override=credential.vault_type) + vault_type = credential.vault_type or CredentialVaultType.BITWARDEN + credential_service = await _get_credential_vault_service(vault_type_override=vault_type) try: credential_item = await credential_service.get_credential_item(credential) except SkyvernHttpException as e: @@ -1981,7 +1972,7 @@ async def get_credentials( ), vault_type: CredentialVaultType | None = Query( default=None, - description="Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')", + description="Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')", ), credential_type: CredentialType | None = Query( default=None, @@ -2886,7 +2877,13 @@ async def _get_credential_vault_service( vault_type_override: CredentialVaultType | None = None, ) -> CredentialVaultService: vault_type = vault_type_override or settings.CREDENTIAL_VAULT_TYPE - if vault_type == CredentialVaultType.BITWARDEN: + if vault_type == CredentialVaultType.SKYVERN: + if not settings.is_local_credential_vault_enabled(): + raise HTTPException(status_code=400, detail="Skyvern local credential vault is not enabled") + if not app.SKYVERN_CREDENTIAL_VAULT_SERVICE: + raise HTTPException(status_code=400, detail="Skyvern local credential vault is not configured") + return app.SKYVERN_CREDENTIAL_VAULT_SERVICE + elif vault_type == CredentialVaultType.BITWARDEN: return app.BITWARDEN_CREDENTIAL_VAULT_SERVICE elif vault_type == CredentialVaultType.AZURE_VAULT: if not app.AZURE_CREDENTIAL_VAULT_SERVICE: @@ -2904,6 +2901,30 @@ async def _get_credential_vault_service( raise HTTPException(status_code=400, detail="Credential storage not supported") +async def _delete_temporary_test_login_credential( + *, + credential_id: str, + organization_id: str, + reason: str, +) -> None: + credential = await app.DATABASE.credentials.get_credential( + credential_id=credential_id, + organization_id=organization_id, + ) + if not credential or not credential.name.startswith("_test_login_"): + return + + vault_type = credential.vault_type or CredentialVaultType.BITWARDEN + credential_service = await _get_credential_vault_service(vault_type_override=vault_type) + await credential_service.delete_credential(credential) + LOG.info( + "Deleted temporary credential", + credential_id=credential_id, + organization_id=organization_id, + reason=reason, + ) + + def _convert_to_response(credential: Credential) -> CredentialResponse: """Convert an internal ``Credential`` to a safe API response. diff --git a/skyvern/forge/sdk/schemas/credentials.py b/skyvern/forge/sdk/schemas/credentials.py index 4b0e87807..b629dd625 100644 --- a/skyvern/forge/sdk/schemas/credentials.py +++ b/skyvern/forge/sdk/schemas/credentials.py @@ -12,6 +12,7 @@ from skyvern.utils.url_validators import validate_url class CredentialVaultType(StrEnum): + SKYVERN = "skyvern" BITWARDEN = "bitwarden" AZURE_VAULT = "azure_vault" GCP = "gcp" @@ -220,7 +221,7 @@ class CreateCredentialRequest(BaseModel): default=None, description="Which vault to store this credential in. If omitted, uses the instance default. " "Use this to mix Skyvern-hosted and custom credentials within the same organization.", - examples=["custom", "azure_vault", "bitwarden"], + examples=["skyvern", "custom", "azure_vault", "bitwarden"], ) proxy_location: ProxyLocationInput = Field( default=None, @@ -257,7 +258,7 @@ class CredentialResponse(BaseModel): name: str = Field(..., description="Name of the credential", examples=["Amazon Login"]) vault_type: CredentialVaultType | None = Field( default=None, - description="Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')", + description="Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')", ) browser_profile_id: str | None = Field(default=None, description="Browser profile ID linked to this credential") tested_url: str | None = Field(default=None, description="Login page URL used during the credential test") @@ -337,7 +338,7 @@ class Credential(BaseModel): ..., description="ID of the organization that owns the credential", examples=["o_1234567890"] ) name: str = Field(..., description="Name of the credential", examples=["Skyvern Login"]) - vault_type: CredentialVaultType | None = Field(..., description="Where the secret is stored: Bitwarden vs Azure") + vault_type: CredentialVaultType | None = Field(..., description="Where the secret is stored") item_id: str = Field(..., description="ID of the associated credential item", examples=["item_1234567890"]) credential_type: CredentialType = Field(..., description="Type of the credential. Eg password, credit card, etc.") username: str | None = Field(..., description="For password credentials: the username") diff --git a/skyvern/forge/sdk/services/credential/skyvern_credential_vault_service.py b/skyvern/forge/sdk/services/credential/skyvern_credential_vault_service.py new file mode 100644 index 000000000..37c8961c9 --- /dev/null +++ b/skyvern/forge/sdk/services/credential/skyvern_credential_vault_service.py @@ -0,0 +1,208 @@ +import json +import os +import re +import secrets +from contextlib import suppress +from pathlib import Path + +import structlog +from cryptography.fernet import Fernet, InvalidToken +from fastapi import HTTPException + +from skyvern.config import settings +from skyvern.forge import app +from skyvern.forge.sdk.schemas.credentials import ( + CreateCredentialRequest, + Credential, + CredentialItem, + CredentialType, + CredentialVaultType, + CreditCardCredential, +) +from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService + +LOG = structlog.get_logger() + +_ITEM_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +class SkyvernCredentialVaultService(CredentialVaultService): + """Local encrypted credential vault for self-hosted Skyvern deployments.""" + + def __init__(self) -> None: + self._fernet: Fernet | None = None + + async def create_credential(self, organization_id: str, data: CreateCredentialRequest) -> Credential: + item_id = self._generate_item_id() + item = CredentialItem( + item_id=item_id, + name=data.name, + credential_type=data.credential_type, + credential=data.credential, + ) + await self._store_item(item) + + try: + return await self._create_db_credential( + organization_id=organization_id, + data=data, + item_id=item_id, + vault_type=CredentialVaultType.SKYVERN, + ) + except Exception: + self._delete_item_file(item_id) + raise + + async def update_credential(self, credential: Credential, data: CreateCredentialRequest) -> Credential: + credential_data = data.credential + if data.credential_type == CredentialType.CREDIT_CARD and isinstance(credential_data, CreditCardCredential): + credential_data = await self._preserve_omitted_credit_card_fields( + credential=credential, + updated_credential=credential_data, + ) + + new_item_id = self._generate_item_id() + item = CredentialItem( + item_id=new_item_id, + name=data.name, + credential_type=data.credential_type, + credential=credential_data, + ) + await self._store_item(item) + + try: + return await self._update_db_credential( + credential=credential, + data=data, + item_id=new_item_id, + ) + except Exception: + self._delete_item_file(new_item_id) + raise + + async def delete_credential(self, credential: Credential) -> None: + await app.DATABASE.credentials.delete_credential(credential.credential_id, credential.organization_id) + self._delete_item_file(credential.item_id) + + async def post_delete_credential_item(self, item_id: str, organization_id: str | None = None) -> None: + self._delete_item_file(item_id) + + async def get_credential_item(self, db_credential: Credential) -> CredentialItem: + item_path = self._item_path(db_credential.item_id) + if not item_path.exists(): + raise HTTPException(status_code=404, detail="Credential vault item not found") + + try: + encrypted_payload = item_path.read_bytes() + payload = self._get_fernet().decrypt(encrypted_payload).decode("utf-8") + item = CredentialItem.model_validate(json.loads(payload)) + except InvalidToken as exc: + raise HTTPException(status_code=500, detail="Credential vault item could not be decrypted") from exc + except Exception as exc: + LOG.warning( + "Failed to read local credential vault item", + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + error_type=type(exc).__name__, + ) + raise HTTPException(status_code=500, detail="Credential vault item could not be read") from exc + + return item + + async def _store_item(self, item: CredentialItem) -> None: + item_path = self._item_path(item.item_id) + payload = json.dumps(item.model_dump(mode="json"), separators=(",", ":")) + encrypted_payload = self._get_fernet().encrypt(payload.encode("utf-8")) + + temp_path = item_path.with_suffix(f".{secrets.token_urlsafe(8)}.tmp") + try: + self._write_file_durable(temp_path, encrypted_payload, mode=0o600) + temp_path.replace(item_path) + self._fsync_directory_best_effort(item_path.parent) + finally: + with suppress(FileNotFoundError): + temp_path.unlink() + + def _get_fernet(self) -> Fernet: + if self._fernet is not None: + return self._fernet + + key = settings.LOCAL_CREDENTIAL_VAULT_KEY + if key: + self._fernet = Fernet(key.encode("utf-8")) + return self._fernet + + vault_dir = self._vault_dir() + key_path = vault_dir / ".fernet_key" + if key_path.exists(): + key_bytes = key_path.read_bytes().strip() + else: + key_bytes = self._create_or_read_key_file(key_path) + + self._fernet = Fernet(key_bytes) + return self._fernet + + def _create_or_read_key_file(self, key_path: Path) -> bytes: + key_bytes = Fernet.generate_key() + temp_path = key_path.with_name(f".fernet_key.{secrets.token_urlsafe(8)}.tmp") + try: + self._write_file_durable(temp_path, key_bytes + b"\n", mode=0o600) + try: + os.link(temp_path, key_path) + self._fsync_directory_best_effort(key_path.parent) + return key_bytes + except FileExistsError: + return key_path.read_bytes().strip() + finally: + with suppress(FileNotFoundError): + temp_path.unlink() + + def _vault_dir(self) -> Path: + vault_dir = Path(settings.LOCAL_CREDENTIAL_VAULT_PATH).expanduser().resolve() + vault_dir.mkdir(parents=True, exist_ok=True) + self._chmod_best_effort(vault_dir, 0o700) + return vault_dir + + def _item_path(self, item_id: str) -> Path: + if not item_id or not _ITEM_ID_PATTERN.fullmatch(item_id): + raise HTTPException(status_code=400, detail="Invalid credential vault item ID") + return self._vault_dir() / f"{item_id}.bin" + + def _delete_item_file(self, item_id: str) -> None: + try: + self._item_path(item_id).unlink(missing_ok=True) + except HTTPException: + raise + except Exception: + LOG.warning("Failed to delete local credential vault item", item_id=item_id, exc_info=True) + + @staticmethod + def _generate_item_id() -> str: + return f"creditem_{secrets.token_urlsafe(24)}" + + @staticmethod + def _chmod_best_effort(path: Path, mode: int) -> None: + with suppress(OSError): + os.chmod(path, mode) + + @staticmethod + def _write_file_durable(path: Path, payload: bytes, *, mode: int) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + try: + with os.fdopen(fd, "wb") as file: + fd = -1 + file.write(payload) + file.flush() + os.fsync(file.fileno()) + finally: + if fd != -1: + os.close(fd) + + @staticmethod + def _fsync_directory_best_effort(path: Path) -> None: + with suppress(OSError): + fd = os.open(path, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) diff --git a/tests/unit/test_credential_vault_type_override.py b/tests/unit/test_credential_vault_type_override.py index f0ce51d87..7bac4c57d 100644 --- a/tests/unit/test_credential_vault_type_override.py +++ b/tests/unit/test_credential_vault_type_override.py @@ -1,11 +1,16 @@ """Tests for per-credential vault_type override in CreateCredentialRequest and vault service routing.""" -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException -from skyvern.forge.sdk.routes.credentials import _get_credential_vault_service +from skyvern.config import Settings +from skyvern.forge.sdk.routes.credentials import ( + _delete_temporary_test_login_credential, + _get_credential_vault_service, +) from skyvern.forge.sdk.schemas.credentials import ( CreateCredentialRequest, CredentialResponse, @@ -19,6 +24,22 @@ from skyvern.forge.sdk.schemas.credentials import ( from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService +class TestLocalCredentialVaultSettings: + """Verify the local filesystem vault is enabled only in local envs by default.""" + + def test_local_credential_vault_defaults_enabled_for_local_env(self) -> None: + settings = Settings(_env_file=None, ENV="local", ENABLE_LOCAL_CREDENTIAL_VAULT=None) + assert settings.is_local_credential_vault_enabled() + + def test_local_credential_vault_defaults_disabled_for_non_local_env(self) -> None: + settings = Settings(_env_file=None, ENV="prod", ENABLE_LOCAL_CREDENTIAL_VAULT=None) + assert not settings.is_local_credential_vault_enabled() + + def test_local_credential_vault_can_be_explicitly_enabled_for_non_local_env(self) -> None: + settings = Settings(_env_file=None, ENV="prod", ENABLE_LOCAL_CREDENTIAL_VAULT=True) + assert settings.is_local_credential_vault_enabled() + + class TestCreateCredentialRequestVaultType: """Verify the optional vault_type field on CreateCredentialRequest.""" @@ -57,6 +78,15 @@ class TestCreateCredentialRequestVaultType: ) assert req.vault_type == CredentialVaultType.BITWARDEN + def test_vault_type_can_be_set_to_skyvern(self) -> None: + req = CreateCredentialRequest( + name="Test", + credential_type=CredentialType.PASSWORD, + credential=NonEmptyPasswordCredential(username="u", password="p"), + vault_type=CredentialVaultType.SKYVERN, + ) + assert req.vault_type == CredentialVaultType.SKYVERN + def test_vault_type_serializes_in_json(self) -> None: req = CreateCredentialRequest( name="Test", @@ -145,6 +175,69 @@ class TestGetCredentialVaultServiceRouting: result = await _get_credential_vault_service() assert result is mock_bw + @pytest.mark.asyncio + async def test_no_override_uses_global_skyvern(self) -> None: + mock_skyvern = MagicMock(spec=CredentialVaultService) + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.SKYVERN + mock_settings.is_local_credential_vault_enabled.return_value = True + mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_skyvern + result = await _get_credential_vault_service() + assert result is mock_skyvern + + @pytest.mark.asyncio + async def test_no_override_skyvern_raises_when_local_vault_disabled(self) -> None: + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.SKYVERN + mock_settings.is_local_credential_vault_enabled.return_value = False + mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = MagicMock(spec=CredentialVaultService) + with pytest.raises(HTTPException) as exc_info: + await _get_credential_vault_service() + assert exc_info.value.status_code == 400 + assert "local credential vault is not enabled" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_override_skyvern_ignores_global(self) -> None: + mock_bw = MagicMock(spec=CredentialVaultService) + mock_skyvern = MagicMock(spec=CredentialVaultService) + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.BITWARDEN + mock_settings.is_local_credential_vault_enabled.return_value = True + mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_bw + mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_skyvern + result = await _get_credential_vault_service( + vault_type_override=CredentialVaultType.SKYVERN, + ) + assert result is mock_skyvern + + @pytest.mark.asyncio + async def test_override_skyvern_raises_when_local_vault_disabled(self) -> None: + mock_bw = MagicMock(spec=CredentialVaultService) + mock_skyvern = MagicMock(spec=CredentialVaultService) + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.BITWARDEN + mock_settings.is_local_credential_vault_enabled.return_value = False + mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_bw + mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_skyvern + with pytest.raises(HTTPException) as exc_info: + await _get_credential_vault_service( + vault_type_override=CredentialVaultType.SKYVERN, + ) + assert exc_info.value.status_code == 400 + assert "local credential vault is not enabled" in str(exc_info.value.detail) + @pytest.mark.asyncio async def test_override_custom_ignores_global(self) -> None: mock_bw = MagicMock(spec=CredentialVaultService) @@ -234,3 +327,88 @@ class TestGetCredentialVaultServiceRouting: mock_app.CUSTOM_CREDENTIAL_VAULT_SERVICE = mock_custom result = await _get_credential_vault_service(vault_type_override=None) assert result is mock_custom + + +class TestTemporaryTestLoginCredentialCleanup: + """Verify temporary login-test credentials are deleted through the owning vault service.""" + + @pytest.mark.asyncio + async def test_deletes_temporary_skyvern_credential_through_vault_service(self) -> None: + credential = SimpleNamespace( + credential_id="cred_temp", + organization_id="org_test", + name="_test_login_example", + vault_type=CredentialVaultType.SKYVERN, + ) + mock_repository = MagicMock() + mock_repository.get_credential = AsyncMock(return_value=credential) + mock_service = MagicMock(spec=CredentialVaultService) + mock_service.delete_credential = AsyncMock() + + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.is_local_credential_vault_enabled.return_value = True + mock_app.DATABASE.credentials = mock_repository + mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_service + + await _delete_temporary_test_login_credential( + credential_id="cred_temp", + organization_id="org_test", + reason="test", + ) + + mock_service.delete_credential.assert_awaited_once_with(credential) + + @pytest.mark.asyncio + async def test_defaults_missing_vault_type_to_bitwarden_for_legacy_credentials(self) -> None: + credential = SimpleNamespace( + credential_id="cred_temp", + organization_id="org_test", + name="_test_login_example", + vault_type=None, + ) + mock_repository = MagicMock() + mock_repository.get_credential = AsyncMock(return_value=credential) + mock_service = MagicMock(spec=CredentialVaultService) + mock_service.delete_credential = AsyncMock() + + with ( + patch("skyvern.forge.sdk.routes.credentials.settings"), + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_app.DATABASE.credentials = mock_repository + mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_service + + await _delete_temporary_test_login_credential( + credential_id="cred_temp", + organization_id="org_test", + reason="test", + ) + + mock_service.delete_credential.assert_awaited_once_with(credential) + + @pytest.mark.asyncio + async def test_ignores_non_temporary_credentials(self) -> None: + credential = SimpleNamespace( + credential_id="cred_regular", + organization_id="org_test", + name="regular credential", + vault_type=CredentialVaultType.SKYVERN, + ) + mock_repository = MagicMock() + mock_repository.get_credential = AsyncMock(return_value=credential) + mock_service = MagicMock(spec=CredentialVaultService) + mock_service.delete_credential = AsyncMock() + + with patch("skyvern.forge.sdk.routes.credentials.app") as mock_app: + mock_app.DATABASE.credentials = mock_repository + + await _delete_temporary_test_login_credential( + credential_id="cred_regular", + organization_id="org_test", + reason="test", + ) + + mock_service.delete_credential.assert_not_awaited() diff --git a/tests/unit/test_skyvern_credential_vault_service.py b/tests/unit/test_skyvern_credential_vault_service.py new file mode 100644 index 000000000..b65981c34 --- /dev/null +++ b/tests/unit/test_skyvern_credential_vault_service.py @@ -0,0 +1,242 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime + +import pytest +from cryptography.fernet import Fernet +from fastapi import HTTPException + +from skyvern.config import settings +from skyvern.forge import app +from skyvern.forge.sdk.schemas.credentials import ( + CreateCredentialRequest, + Credential, + CredentialType, + CredentialVaultType, + NonEmptyPasswordCredential, +) +from skyvern.forge.sdk.services.credential.skyvern_credential_vault_service import ( + SkyvernCredentialVaultService, +) + + +class _FakeCredentialRepository: + def __init__(self) -> None: + self.deleted: tuple[str, str] | None = None + + async def create_credential(self, **kwargs: object) -> Credential: + return self._credential( + credential_id="cred_test", + organization_id=str(kwargs["organization_id"]), + name=str(kwargs["name"]), + vault_type=kwargs["vault_type"], + item_id=str(kwargs["item_id"]), + credential_type=kwargs["credential_type"], + username=kwargs["username"], + ) + + async def update_credential_vault_data(self, **kwargs: object) -> Credential: + return self._credential( + credential_id=str(kwargs["credential_id"]), + organization_id=str(kwargs["organization_id"]), + name=str(kwargs["name"]), + vault_type=CredentialVaultType.SKYVERN, + item_id=str(kwargs["item_id"]), + credential_type=kwargs["credential_type"], + username=kwargs["username"], + ) + + async def delete_credential(self, credential_id: str, organization_id: str) -> None: + self.deleted = (credential_id, organization_id) + + @staticmethod + def _credential( + *, + credential_id: str, + organization_id: str, + name: str, + vault_type: object, + item_id: str, + credential_type: object, + username: object, + ) -> Credential: + return Credential( + credential_id=credential_id, + organization_id=organization_id, + name=name, + vault_type=vault_type, + item_id=item_id, + credential_type=credential_type, + username=username, + totp_type="none", + totp_identifier=None, + card_last4=None, + card_brand=None, + secret_label=None, + browser_profile_id=None, + tested_url=None, + user_context=None, + save_browser_session_intent=False, + folder_id=None, + created_at=datetime(2026, 1, 1), + modified_at=datetime(2026, 1, 1), + deleted_at=None, + ) + + +class _FailingCreateCredentialRepository(_FakeCredentialRepository): + async def create_credential(self, **kwargs: object) -> Credential: + raise RuntimeError("database unavailable") + + +class _FailingUpdateCredentialRepository(_FakeCredentialRepository): + async def update_credential_vault_data(self, **kwargs: object) -> Credential: + raise RuntimeError("database unavailable") + + +def _password_request(name: str = "Login", password: str = "secret-password") -> CreateCredentialRequest: + return CreateCredentialRequest( + name=name, + credential_type=CredentialType.PASSWORD, + credential=NonEmptyPasswordCredential(username="user@example.com", password=password), + ) + + +@pytest.mark.asyncio +async def test_skyvern_credential_vault_round_trips_encrypted_password(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None) + + repository = _FakeCredentialRepository() + monkeypatch.setattr(app.DATABASE, "credentials", repository) + + service = SkyvernCredentialVaultService() + request = _password_request() + + credential = await service.create_credential("org_test", request) + + assert credential.vault_type == CredentialVaultType.SKYVERN + item_path = tmp_path / f"{credential.item_id}.bin" + assert item_path.exists() + assert b"secret-password" not in item_path.read_bytes() + + item = await service.get_credential_item(credential) + assert item.name == "Login" + assert item.credential_type == CredentialType.PASSWORD + assert item.credential.username == "user@example.com" + assert item.credential.password == "secret-password" + + await service.delete_credential(credential) + assert repository.deleted == ("cred_test", "org_test") + assert not item_path.exists() + + +@pytest.mark.asyncio +async def test_skyvern_credential_vault_update_creates_new_item_and_cleans_old(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None) + + monkeypatch.setattr(app.DATABASE, "credentials", _FakeCredentialRepository()) + + service = SkyvernCredentialVaultService() + request = _password_request(password="old-password") + credential = await service.create_credential("org_test", request) + old_item_id = credential.item_id + + update_request = _password_request(name="Login Updated", password="new-password") + updated = await service.update_credential(credential, update_request) + + assert updated.item_id != old_item_id + assert (tmp_path / f"{old_item_id}.bin").exists() + + await service.post_delete_credential_item(old_item_id) + assert not (tmp_path / f"{old_item_id}.bin").exists() + + item = await service.get_credential_item(updated) + assert item.name == "Login Updated" + assert item.credential.password == "new-password" + + +@pytest.mark.asyncio +async def test_skyvern_credential_vault_cleans_item_when_create_db_fails(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None) + monkeypatch.setattr(app.DATABASE, "credentials", _FailingCreateCredentialRepository()) + + service = SkyvernCredentialVaultService() + + with pytest.raises(RuntimeError, match="database unavailable"): + await service.create_credential("org_test", _password_request()) + + assert list(tmp_path.glob("*.bin")) == [] + + +@pytest.mark.asyncio +async def test_skyvern_credential_vault_cleans_new_item_when_update_db_fails(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None) + monkeypatch.setattr(app.DATABASE, "credentials", _FailingUpdateCredentialRepository()) + + service = SkyvernCredentialVaultService() + credential = await service.create_credential("org_test", _password_request(password="old-password")) + old_item_path = tmp_path / f"{credential.item_id}.bin" + + with pytest.raises(RuntimeError, match="database unavailable"): + await service.update_credential( + credential, + _password_request(name="Login Updated", password="new-password"), + ) + + assert old_item_path.exists() + assert list(tmp_path.glob("*.bin")) == [old_item_path] + + +@pytest.mark.asyncio +async def test_skyvern_credential_vault_rejects_invalid_item_id(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None) + credential = _FakeCredentialRepository._credential( + credential_id="cred_test", + organization_id="org_test", + name="Login", + vault_type=CredentialVaultType.SKYVERN, + item_id="../outside", + credential_type=CredentialType.PASSWORD, + username="user@example.com", + ) + + with pytest.raises(HTTPException) as exc_info: + await SkyvernCredentialVaultService().get_credential_item(credential) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_skyvern_credential_vault_wrong_key_cannot_decrypt_item(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", Fernet.generate_key().decode("utf-8")) + monkeypatch.setattr(app.DATABASE, "credentials", _FakeCredentialRepository()) + + credential = await SkyvernCredentialVaultService().create_credential("org_test", _password_request()) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", Fernet.generate_key().decode("utf-8")) + + with pytest.raises(HTTPException) as exc_info: + await SkyvernCredentialVaultService().get_credential_item(credential) + + assert exc_info.value.status_code == 500 + + +def test_skyvern_credential_vault_key_file_creation_is_single_winner(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path)) + monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None) + key_path = tmp_path / ".fernet_key" + + def create_key_file() -> bytes: + return SkyvernCredentialVaultService()._create_or_read_key_file(key_path) + + with ThreadPoolExecutor(max_workers=8) as executor: + returned_keys = list(executor.map(lambda _: create_key_file(), range(8))) + + stored_key = key_path.read_bytes().strip() + assert len(set(returned_keys)) == 1 + assert returned_keys[0] == stored_key + assert list(tmp_path.glob("*.tmp")) == []