Skip to content

local credential support#4578

Open
wintonzheng wants to merge 1 commit intomainfrom
shu/local_credential_support
Open

local credential support#4578
wintonzheng wants to merge 1 commit intomainfrom
shu/local_credential_support

Conversation

@wintonzheng
Copy link
Contributor

@wintonzheng wintonzheng commented Jan 29, 2026


🔧 This PR introduces local file-based credential storage support for Skyvern, enabling development, testing, and hobby self-hosting scenarios by storing credentials in a local JSON file as an alternative to cloud-based vault services.

🔍 Detailed Analysis

Key Changes

  • New Credential Vault Type: Added LOCAL as a new CredentialVaultType enum value alongside existing Bitwarden, Azure, and Custom options
  • Local Storage Service: Implemented LocalCredentialVaultService that stores credentials in a configurable JSON file (.skyvern_credentials.json by default)
  • Configuration Support: Added LOCAL_CREDENTIAL_FILE setting to specify the credential storage file path
  • Service Integration: Integrated the local service into the main application factory and routing system

Technical Implementation

sequenceDiagram
    participant Client
    participant API
    participant LocalService
    participant FileSystem
    participant Database

    Client->>API: Create Credential Request
    API->>LocalService: create_credential()
    LocalService->>FileSystem: Read existing store
    LocalService->>FileSystem: Write updated store
    LocalService->>Database: Create DB record
    LocalService-->>API: Return Credential
    API-->>Client: Credential Response

    Client->>API: Get Credential Request
    API->>Database: Fetch credential metadata
    API->>LocalService: get_credential_item()
    LocalService->>FileSystem: Read credential data
    LocalService-->>API: Return CredentialItem
    API-->>Client: Full credential data
Loading

Impact

  • Development Experience: Simplifies local development by eliminating the need for external credential vault services during testing
  • Self-Hosting Support: Enables hobby users and small deployments to use Skyvern without cloud dependencies
  • Security Consideration: Credentials are stored unencrypted with explicit warnings about production usage limitations
  • Atomic Operations: Implements file locking and atomic writes to prevent data corruption during concurrent access
  • Backward Compatibility: Maintains full compatibility with existing credential vault implementations

Created with Palmier


Important

Adds local credential storage support via LocalCredentialVaultService for development and testing, with configuration and integration updates.

  • Local Credential Storage:
    • Adds LocalCredentialVaultService in local_credential_vault_service.py for local file-based credential storage.
    • Supports password, credit_card, and secret credential types.
    • Credentials stored unencrypted; not for production use.
  • Configuration:
    • Adds LOCAL_CREDENTIAL_FILE setting in config.py for specifying local credential file path.
    • Updates CredentialVaultType in schemas/credentials.py to include LOCAL.
  • Integration:
    • Modifies create_forge_app() in forge_app.py to initialize LocalCredentialVaultService.
    • Updates _get_credential_vault_service() in routes/credentials.py to handle LOCAL vault type.

This description was created by Ellipsis for 73933ac. You can customize this summary. It will automatically update as commits are pushed.

Summary by CodeRabbit

New Features

  • Added local credential vault option for storing credentials locally in development and self-hosted environments
  • Includes secure file-based storage with configurable storage location
  • Supports passwords, payment cards, and API secrets

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 29, 2026

Walkthrough

This PR introduces a local file-based credential vault service for development and self-hosted deployments. It adds configuration settings, a new LocalCredentialVaultService class for managing credentials in JSON files with atomic writes and locking, integrates it into the ForgeApp system, and extends the credential routing layer to support the LOCAL vault type.

Changes

Cohort / File(s) Summary
Configuration
skyvern/config.py
Added LOCAL_CREDENTIAL_FILE setting with default path ".skyvern_credentials.json" for local credential storage.
ForgeApp Integration
skyvern/forge/forge_app.py
Imported and initialized LocalCredentialVaultService, added LOCAL_CREDENTIAL_VAULT_SERVICE field, and mapped CredentialVaultType.LOCAL to the service.
Credential Routing
skyvern/forge/sdk/routes/credentials.py
Extended _get_credential_vault_service to handle LOCAL vault type with validation.
Credential Schema
skyvern/forge/sdk/schemas/credentials.py
Added LOCAL member to CredentialVaultType enum.
Local Vault Implementation
skyvern/forge/sdk/services/credential/local_credential_vault_service.py
New service class providing file-based credential storage with atomic writes, asyncio locking, serialization/deserialization, database coordination, and rollback on failure.

Sequence Diagram

sequenceDiagram
    actor User
    participant API as API Request
    participant Router as Credential Router
    participant Service as LocalCredentialVaultService
    participant FileSystem as JSON File Store
    participant Database as Credentials DB

    User->>API: Create/Retrieve Credential
    API->>Router: Route to vault service
    Router->>Service: Get vault service (type=LOCAL)
    
    rect rgba(100, 150, 200, 0.5)
    Note over Service,FileSystem: Create Credential Flow
    Service->>FileSystem: Acquire lock & read store
    Service->>FileSystem: Add credential entry (with generated item_id)
    Service->>FileSystem: Write store atomically (temp + rename)
    Service->>Database: Create credential record
    alt Database fails
        Service->>FileSystem: Rollback store deletion
    end
    end
    
    rect rgba(150, 200, 150, 0.5)
    Note over Service,FileSystem: Retrieve Credential Flow
    Service->>FileSystem: Acquire lock & read store
    Service->>FileSystem: Lookup credential by item_id
    Service->>Service: Deserialize credential data
    Service-->>API: Return mapped CredentialItem
    end
    
    API-->>User: Credential response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

sync

Poem

🐰 In local files, credentials rest,
For dev and testing, they're the best,
With locks and atomicity so tight,
Our rabbit keeps the data right,
No cloud needed when testing near! 🗝️✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'local credential support' is directly aligned with the changeset, which introduces a new LocalCredentialVaultService enabling credential storage for local development environments.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@ellipsis-dev ellipsis-dev bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Looks good to me! 👍

Reviewed everything up to 73933ac in 45 seconds. Click for details.
  • Reviewed 393 lines of code in 5 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_kDWgAcq0LZnWxRfX

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@skyvern/forge/sdk/services/credential/local_credential_vault_service.py`:
- Around line 36-41: The _PasswordCredentialData model currently lacks
totp_identifier and totp_type fields causing loss of that data when persisting
PasswordCredential; add totp_identifier: str | None = None and totp_type: str |
None = None to the _PasswordCredentialData BaseModel and update the
serialization/deserialization logic where PasswordCredential instances are
converted to/from _PasswordCredentialData (the block that writes/reads totp) to
include totp_identifier and totp_type so they are persisted and restored
alongside totp.
- Around line 201-212: Wrap the rollback that removes the credential from local
storage in its own try-except so failures in self._write_store don't silently
leave orphaned credentials; inside the except, log a warning/error via LOG
(including organization_id, item_id and the caught exception) and consider
recording the failure for later cleanup, then re-raise the original exception
from the outer block (or attach both errors) so callers still see the DB
creation error; update the block around async with self._lock / store = await
self._read_store() / store.credentials.pop(...) / await self._write_store(store)
to implement this handling.
🧹 Nitpick comments (3)
skyvern/forge/sdk/services/credential/local_credential_vault_service.py (3)

14-14: Potential circular import with app module.

Importing app at module level may cause circular import issues since forge_app.py imports this service. Consider importing app inside the methods that need it, or accessing it through a parameter.

♻️ Proposed refactor to avoid circular import
-from skyvern.forge import app

Then, in the methods that need app, import it locally:

async def delete_credential(self, credential: Credential) -> None:
    from skyvern.forge import app
    # ... rest of the method

231-239: Consider reversing deletion order for better consistency.

Deleting from DB first (line 232) means if the local file operation fails, the credential is lost from DB but remains in local storage. Reversing the order (local first, then DB) would be safer—orphaned DB records are easier to detect and clean up than orphaned local entries.

♻️ Proposed refactor to delete local first
     async def delete_credential(self, credential: Credential) -> None:
         """Delete a credential from the local file and database."""
         LOG.info(
             "Deleting credential from local storage",
             organization_id=credential.organization_id,
             credential_id=credential.credential_id,
             item_id=credential.item_id,
         )

-        # Delete from database first
-        await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id)
-
         # Delete from local storage
         async with self._lock:
             store = await self._read_store()
             if credential.item_id in store.credentials:
                 del store.credentials[credential.item_id]
                 await self._write_store(store)

+        # Delete from database after local storage
+        from skyvern.forge import app
+        await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id)
+
         LOG.info(
             "Successfully deleted credential from local storage",
             organization_id=credential.organization_id,
             credential_id=credential.credential_id,
         )

106-111: Use LOG.exception instead of LOG.error in exception handlers.

Per static analysis (Ruff TRY400), using LOG.exception automatically includes the traceback, which aids debugging file I/O issues. This applies to lines 107, 110, and 130.

♻️ Proposed fix
         except json.JSONDecodeError as e:
-            LOG.error("Failed to parse local credential file", error=str(e), file_path=str(self._file_path))
+            LOG.exception("Failed to parse local credential file", file_path=str(self._file_path))
             raise ValueError(f"Invalid JSON in credential file: {e}") from e
         except Exception as e:
-            LOG.error("Failed to read local credential file", error=str(e), file_path=str(self._file_path))
+            LOG.exception("Failed to read local credential file", file_path=str(self._file_path))
             raise ValueError(f"Failed to read credential file: {e}") from e

Comment on lines +36 to +41
class _PasswordCredentialData(BaseModel):
type: Literal["password"]
username: str
password: str
totp: str | None = None

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing totp_identifier and totp_type in local storage model.

The _PasswordCredentialData model stores totp but omits totp_identifier and totp_type fields that exist on PasswordCredential. When credentials are retrieved, totp_type comes from the DB record, but totp_identifier is never persisted to the local file and will be lost.

🔧 Proposed fix to include missing fields
     class _PasswordCredentialData(BaseModel):
         type: Literal["password"]
         username: str
         password: str
         totp: str | None = None
+        totp_type: str = "none"
+        totp_identifier: str | None = None

And update serialization at line 146:

             return self._PasswordCredentialData(
                 type="password",
                 username=credential.username,
                 password=credential.password,
                 totp=credential.totp,
+                totp_type=credential.totp_type.value if hasattr(credential, 'totp_type') else "none",
+                totp_identifier=credential.totp_identifier if hasattr(credential, 'totp_identifier') else None,
             ).model_dump()
🤖 Prompt for AI Agents
In `@skyvern/forge/sdk/services/credential/local_credential_vault_service.py`
around lines 36 - 41, The _PasswordCredentialData model currently lacks
totp_identifier and totp_type fields causing loss of that data when persisting
PasswordCredential; add totp_identifier: str | None = None and totp_type: str |
None = None to the _PasswordCredentialData BaseModel and update the
serialization/deserialization logic where PasswordCredential instances are
converted to/from _PasswordCredentialData (the block that writes/reads totp) to
include totp_identifier and totp_type so they are persisted and restored
alongside totp.

Comment on lines +201 to +212
except Exception:
# Rollback: remove from local storage
LOG.warning(
"DB creation failed, rolling back local storage",
organization_id=organization_id,
item_id=item_id,
)
async with self._lock:
store = await self._read_store()
store.credentials.pop(item_id, None)
await self._write_store(store)
raise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Rollback may leave orphaned credentials in local storage.

If the rollback _write_store call at line 211 fails (e.g., disk full, permissions), the credential remains in local storage without a corresponding DB record. Consider wrapping the rollback in a try-except and logging the failure so it can be cleaned up later.

🛡️ Proposed fix to handle rollback failures
         except Exception:
             # Rollback: remove from local storage
             LOG.warning(
                 "DB creation failed, rolling back local storage",
                 organization_id=organization_id,
                 item_id=item_id,
             )
-            async with self._lock:
-                store = await self._read_store()
-                store.credentials.pop(item_id, None)
-                await self._write_store(store)
+            try:
+                async with self._lock:
+                    store = await self._read_store()
+                    store.credentials.pop(item_id, None)
+                    await self._write_store(store)
+            except Exception as rollback_error:
+                LOG.exception(
+                    "Failed to rollback local storage after DB failure, orphaned credential may exist",
+                    organization_id=organization_id,
+                    item_id=item_id,
+                    rollback_error=str(rollback_error),
+                )
             raise
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception:
# Rollback: remove from local storage
LOG.warning(
"DB creation failed, rolling back local storage",
organization_id=organization_id,
item_id=item_id,
)
async with self._lock:
store = await self._read_store()
store.credentials.pop(item_id, None)
await self._write_store(store)
raise
except Exception:
# Rollback: remove from local storage
LOG.warning(
"DB creation failed, rolling back local storage",
organization_id=organization_id,
item_id=item_id,
)
try:
async with self._lock:
store = await self._read_store()
store.credentials.pop(item_id, None)
await self._write_store(store)
except Exception as rollback_error:
LOG.exception(
"Failed to rollback local storage after DB failure, orphaned credential may exist",
organization_id=organization_id,
item_id=item_id,
rollback_error=str(rollback_error),
)
raise
🤖 Prompt for AI Agents
In `@skyvern/forge/sdk/services/credential/local_credential_vault_service.py`
around lines 201 - 212, Wrap the rollback that removes the credential from local
storage in its own try-except so failures in self._write_store don't silently
leave orphaned credentials; inside the except, log a warning/error via LOG
(including organization_id, item_id and the caught exception) and consider
recording the failure for later cleanup, then re-raise the original exception
from the outer block (or attach both errors) so callers still see the DB
creation error; update the block around async with self._lock / store = await
self._read_store() / store.credentials.pop(...) / await self._write_store(store)
to implement this handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant